mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
Initial Commit
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{feature,json,md,yaml,yml,toml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
234
.gitignore
vendored
Normal file
234
.gitignore
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
# unshackle
|
||||
*.mkv
|
||||
*.mp4
|
||||
*.exe
|
||||
*.dll
|
||||
*.crt
|
||||
*.wvd
|
||||
*.prd
|
||||
*.der
|
||||
*.pem
|
||||
*.bin
|
||||
*.db
|
||||
*.ttf
|
||||
*.otf
|
||||
device_cert
|
||||
device_client_id_blob
|
||||
device_private_key
|
||||
device_vmp_blob
|
||||
binaries/
|
||||
unshackle/cache/
|
||||
unshackle/cookies/
|
||||
unshackle/certs/
|
||||
unshackle/WVDs/
|
||||
unshackle/PRDs/
|
||||
temp/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
.vscode/
|
||||
.github/copilot-instructions.md
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
23
.pre-commit-config.yaml
Normal file
23
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
||||
rev: v4.2.0
|
||||
hooks:
|
||||
- id: conventional-pre-commit
|
||||
stages: [commit-msg]
|
||||
- repo: https://github.com/mtkennerly/pre-commit-hooks
|
||||
rev: v0.4.0
|
||||
hooks:
|
||||
- id: poetry-ruff-check
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 6.0.1
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
449
CONFIG.md
Normal file
449
CONFIG.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Config Documentation
|
||||
|
||||
This page documents configuration values and what they do. You begin with an empty configuration file.
|
||||
You may alter your configuration with `unshackle cfg --help`, or find the direct location with `unshackle env info`.
|
||||
Configuration values are listed in alphabetical order.
|
||||
|
||||
Avoid putting comments in the config file as they may be removed. Comments are currently kept only thanks
|
||||
to the usage of `ruamel.yaml` to parse and write YAML files. In the future `yaml` may be used instead,
|
||||
which does not keep comments.
|
||||
|
||||
## aria2c (dict)
|
||||
|
||||
- `max_concurrent_downloads`
|
||||
Maximum number of parallel downloads. Default: `min(32,(cpu_count+4))`
|
||||
Note: Overrides the `max_workers` parameter of the aria2(c) downloader function.
|
||||
- `max_connection_per_server`
|
||||
Maximum number of connections to one server for each download. Default: `1`
|
||||
- `split`
|
||||
Split a file into N chunks and download each chunk on its own connection. Default: `5`
|
||||
- `file_allocation`
|
||||
Specify file allocation method. Default: `"prealloc"`
|
||||
|
||||
- `"none"` doesn't pre-allocate file space.
|
||||
- `"prealloc"` pre-allocates file space before download begins. This may take some time depending on the size of the
|
||||
file.
|
||||
- `"falloc"` is your best choice if you are using newer file systems such as ext4 (with extents support), btrfs, xfs
|
||||
or NTFS (MinGW build only). It allocates large(few GiB) files almost instantly. Don't use falloc with legacy file
|
||||
systems such as ext3 and FAT32 because it takes almost same time as prealloc, and it blocks aria2 entirely until
|
||||
allocation finishes. falloc may not be available if your system doesn't have posix_fallocate(3) function.
|
||||
- `"trunc"` uses ftruncate(2) system call or platform-specific counterpart to truncate a file to a specified length.
|
||||
|
||||
## cdm (dict)
|
||||
|
||||
Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-sensitive).
|
||||
The value should be a WVD or PRD filename without the file extension. When
|
||||
loading the device, unshackle will look in both the `WVDs` and `PRDs` directories
|
||||
for a matching file.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
AMZN: chromecdm_903_l3
|
||||
NF: nexus_6_l1
|
||||
```
|
||||
|
||||
You may also specify this device based on the profile used.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
AMZN: chromecdm_903_l3
|
||||
NF: nexus_6_l1
|
||||
DSNP:
|
||||
john_sd: chromecdm_903_l3
|
||||
jane_uhd: nexus_5_l1
|
||||
```
|
||||
|
||||
You can also specify a fallback value to predefine if a match was not made.
|
||||
This can be done using `default` key. This can help reduce redundancy in your specifications.
|
||||
|
||||
For example, the following has the same result as the previous example, as well as all other
|
||||
services and profiles being pre-defined to use `chromecdm_903_l3`.
|
||||
|
||||
```yaml
|
||||
NF: nexus_6_l1
|
||||
DSNP:
|
||||
jane_uhd: nexus_5_l1
|
||||
default: chromecdm_903_l3
|
||||
```
|
||||
|
||||
## chapter_fallback_name (str)
|
||||
|
||||
The Chapter Name to use when exporting a Chapter without a Name.
|
||||
The default is no fallback name at all and no Chapter name will be set.
|
||||
|
||||
The fallback name can use the following variables in f-string style:
|
||||
|
||||
- `{i}`: The Chapter number starting at 1.
|
||||
E.g., `"Chapter {i}"`: "Chapter 1", "Intro", "Chapter 3".
|
||||
- `{j}`: A number starting at 1 that increments any time a Chapter has no title.
|
||||
E.g., `"Chapter {j}"`: "Chapter 1", "Intro", "Chapter 2".
|
||||
|
||||
These are formatted with f-strings, directives are supported.
|
||||
For example, `"Chapter {i:02}"` will result in `"Chapter 01"`.
|
||||
|
||||
## credentials (dict[str, str|list|dict])
|
||||
|
||||
Specify login credentials to use for each Service, and optionally per-profile.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
ALL4: jane@gmail.com:LoremIpsum100 # directly
|
||||
AMZN: # or per-profile, optionally with a default
|
||||
default: jane@example.tld:LoremIpsum99 # <-- used by default if -p/--profile is not used
|
||||
james: james@gmail.com:TheFriend97
|
||||
john: john@example.tld:LoremIpsum98
|
||||
NF: # the `default` key is not necessary, but no credential will be used by default
|
||||
john: john@gmail.com:TheGuyWhoPaysForTheNetflix69420
|
||||
```
|
||||
|
||||
The value should be in string form, i.e. `john@gmail.com:password123` or `john:password123`.
|
||||
Any arbitrary values can be used on the left (username/password/phone) and right (password/secret).
|
||||
You can also specify these in list form, i.e., `["john@gmail.com", ":PasswordWithAColon"]`.
|
||||
|
||||
If you specify multiple credentials with keys like the `AMZN` and `NF` example above, then you should
|
||||
use a `default` key or no credential will be loaded automatically unless you use `-p/--profile`. You
|
||||
do not have to use a `default` key at all.
|
||||
|
||||
Please be aware that this information is sensitive and to keep it safe. Do not share your config.
|
||||
|
||||
## curl_impersonate (dict)
|
||||
|
||||
- `browser` - The Browser to impersonate as. A list of available Browsers and Versions are listed here:
|
||||
<https://github.com/yifeikong/curl_cffi#sessions>
|
||||
|
||||
## directories (dict)
|
||||
|
||||
Override the default directories used across unshackle.
|
||||
The directories are set to common values by default.
|
||||
|
||||
The following directories are available and may be overridden,
|
||||
|
||||
- `commands` - CLI Command Classes.
|
||||
- `services` - Service Classes.
|
||||
- `vaults` - Vault Classes.
|
||||
- `fonts` - Font files (ttf or otf).
|
||||
- `downloads` - Downloads.
|
||||
- `temp` - Temporary files or conversions during download.
|
||||
- `cache` - Expiring data like Authorization tokens, or other misc data.
|
||||
- `cookies` - Expiring Cookie data.
|
||||
- `logs` - Logs.
|
||||
- `wvds` - Widevine Devices.
|
||||
- `prds` - PlayReady Devices.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
downloads: "D:/Downloads/unshackle"
|
||||
temp: "D:/Temp/unshackle"
|
||||
```
|
||||
|
||||
There are directories not listed that cannot be modified as they are crucial to the operation of unshackle.
|
||||
|
||||
## dl (dict)
|
||||
|
||||
Pre-define default options and switches of the `dl` command.
|
||||
The values will be ignored if explicitly set in the CLI call.
|
||||
|
||||
The Key must be the same value Python click would resolve it to as an argument.
|
||||
E.g., `@click.option("-r", "--range", "range_", type=...` actually resolves as `range_` variable.
|
||||
|
||||
For example to set the default primary language to download to German,
|
||||
|
||||
```yaml
|
||||
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,
|
||||
|
||||
```yaml
|
||||
lang: de
|
||||
AMZN:
|
||||
bitrate: CVBR
|
||||
```
|
||||
|
||||
or to change the output subtitle format from the default (original format) to WebVTT,
|
||||
|
||||
```yaml
|
||||
sub_format: vtt
|
||||
```
|
||||
|
||||
## downloader (str | dict)
|
||||
|
||||
Choose what software to use to download data throughout unshackle where needed.
|
||||
You may provide a single downloader globally or a mapping of service tags to
|
||||
downloaders.
|
||||
|
||||
Options:
|
||||
|
||||
- `requests` (default) - https://github.com/psf/requests
|
||||
- `aria2c` - https://github.com/aria2/aria2
|
||||
- `curl_impersonate` - https://github.com/yifeikong/curl-impersonate (via https://github.com/yifeikong/curl_cffi)
|
||||
|
||||
Note that aria2c can reach the highest speeds as it utilizes threading and more connections than the other downloaders. However, aria2c can also be one of the more unstable downloaders. It will work one day, then not another day. It also does not support HTTP(S) proxies while the other downloaders do.
|
||||
|
||||
Example mapping:
|
||||
|
||||
```yaml
|
||||
downloader:
|
||||
NF: requests
|
||||
AMZN: n_m3u8dl_re
|
||||
DSNP: n_m3u8dl_re
|
||||
default: requests
|
||||
```
|
||||
|
||||
The `default` entry is optional. If omitted, `requests` will be used for services not listed.
|
||||
|
||||
## headers (dict)
|
||||
|
||||
Case-Insensitive dictionary of headers that all Services begin their Request Session state with.
|
||||
All requests will use these unless changed explicitly or implicitly via a Server response.
|
||||
These should be sane defaults and anything that would only be useful for some Services should not
|
||||
be put here.
|
||||
|
||||
Avoid headers like 'Accept-Encoding' as that would be a compatibility header that Python-requests will
|
||||
set for you.
|
||||
|
||||
I recommend using,
|
||||
|
||||
```yaml
|
||||
Accept-Language: "en-US,en;q=0.8"
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36"
|
||||
```
|
||||
|
||||
## key_vaults (list\[dict])
|
||||
|
||||
Key Vaults store your obtained Content Encryption Keys (CEKs) and Key IDs per-service.
|
||||
|
||||
This can help reduce unnecessary License calls even during the first download. This is because a Service may
|
||||
provide the same Key ID and CEK for both Video and Audio, as well as for multiple resolutions or bitrates.
|
||||
|
||||
You can have as many Key Vaults as you would like. It's nice to share Key Vaults or use a unified Vault on
|
||||
Teams as sharing CEKs immediately can help reduce License calls drastically.
|
||||
|
||||
Three types of Vaults are in the Core codebase, API, SQLite and MySQL. API makes HTTP requests to a RESTful API,
|
||||
whereas SQLite and MySQL directly connect to an SQLite or MySQL Database.
|
||||
|
||||
Note: SQLite and MySQL vaults have to connect directly to the Host/IP. It cannot be in front of a PHP API or such.
|
||||
Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be
|
||||
accessible outside their hosting platform.
|
||||
|
||||
### Using an API Vault
|
||||
|
||||
API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may
|
||||
not work in unshackle. The API format can be seen in the [API Vault Code](unshackle/vaults/API.py).
|
||||
|
||||
```yaml
|
||||
- type: API
|
||||
name: "John#0001's Vault" # arbitrary vault name
|
||||
uri: "https://key-vault.example.com" # api base uri (can also be an IP or IP:Port)
|
||||
# uri: "127.0.0.1:80/key-vault"
|
||||
# uri: "https://api.example.com/key-vault"
|
||||
token: "random secret key" # authorization token
|
||||
```
|
||||
|
||||
### Using a MySQL Vault
|
||||
|
||||
MySQL vaults can be either MySQL or MariaDB servers. I recommend MariaDB.
|
||||
A MySQL Vault can be on a local or remote network, but I recommend SQLite for local Vaults.
|
||||
|
||||
```yaml
|
||||
- type: MySQL
|
||||
name: "John#0001's Vault" # arbitrary vault name
|
||||
host: "127.0.0.1" # host/ip
|
||||
# port: 3306 # port (defaults to 3306)
|
||||
database: vault # database used for unshackle
|
||||
username: jane11
|
||||
password: Doe123
|
||||
```
|
||||
|
||||
I recommend giving only a trustable user (or yourself) CREATE permission and then use unshackle to cache at least one CEK
|
||||
per Service to have it create the tables. If you don't give any user permissions to create tables, you will need to
|
||||
make tables yourself.
|
||||
|
||||
- Use a password on all user accounts.
|
||||
- Never use the root account with unshackle (even if it's you).
|
||||
- Do not give multiple users the same username and/or password.
|
||||
- Only give users access to the database used for unshackle.
|
||||
- You may give trusted users CREATE permission so unshackle can create tables if needed.
|
||||
- Other uses should only be given SELECT and INSERT permissions.
|
||||
|
||||
### Using an SQLite Vault
|
||||
|
||||
SQLite Vaults are usually only used for locally stored vaults. This vault may be stored on a mounted Cloud storage
|
||||
drive, but I recommend using SQLite exclusively as an offline-only vault. Effectively this is your backup vault in
|
||||
case something happens to your MySQL Vault.
|
||||
|
||||
```yaml
|
||||
- type: SQLite
|
||||
name: "My Local Vault" # arbitrary vault name
|
||||
path: "C:/Users/Jane11/Documents/unshackle/data/key_vault.db"
|
||||
```
|
||||
|
||||
**Note**: You do not need to create the file at the specified path.
|
||||
SQLite will create a new SQLite database at that path if one does not exist.
|
||||
Try not to accidentally move the `db` file once created without reflecting the change in the config, or you will end
|
||||
up with multiple databases.
|
||||
|
||||
If you work on a Team I recommend every team member having their own SQLite Vault even if you all use a MySQL vault
|
||||
together.
|
||||
|
||||
## muxing (dict)
|
||||
|
||||
- `set_title`
|
||||
Set the container title to `Show SXXEXX Episode Name` or `Movie (Year)`. Default: `true`
|
||||
|
||||
## proxy_providers (dict)
|
||||
|
||||
Enable external proxy provider services. These proxies will be used automatically where needed as defined by the
|
||||
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])
|
||||
|
||||
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`.
|
||||
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,
|
||||
|
||||
```yaml
|
||||
us:
|
||||
- "http://john%40email.tld:password123@proxy-us.domain.tld:8080"
|
||||
- "http://jane%40email.tld:password456@proxy-us.domain2.tld:8080"
|
||||
de: "https://127.0.0.1:8080"
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy
|
||||
system where required.
|
||||
|
||||
You can also specify specific servers to use per-region with the `servers` key.
|
||||
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
|
||||
password: wXVHmht22hhRKUEQ32PQVjCZ
|
||||
servers:
|
||||
- us: 12 # force US server #12 for US proxies
|
||||
```
|
||||
|
||||
The username and password should NOT be your normal NordVPN Account Credentials.
|
||||
They should be the `Service credentials` which can be found on your Nord Account Dashboard.
|
||||
|
||||
Once set, you can also specifically opt in to use a NordVPN proxy by specifying `--proxy=gb` or such.
|
||||
You can even set a specific server number this way, e.g., `--proxy=gb2366`.
|
||||
|
||||
Note that `gb` is used instead of `uk` to be more consistent across regional systems.
|
||||
|
||||
## remote_cdm (list\[dict])
|
||||
|
||||
Use [pywidevine] Serve-compliant Remote CDMs in unshackle as if it was a local widevine device file.
|
||||
The name of each defined device maps as if it was a local device and should be used like a local device.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
- name: chromecdm_903_l3 # name must be unique for each remote CDM
|
||||
# the device type, system id and security level must match the values of the device on the API
|
||||
# if any of the information is wrong, it will raise an error, if you do not know it ask the API owner
|
||||
device_type: CHROME
|
||||
system_id: 1234
|
||||
security_level: 3
|
||||
host: "http://xxxxxxxxxxxxxxxx/the_cdm_endpoint"
|
||||
secret: "secret/api key"
|
||||
device_name: "remote device to use" # the device name from the API, usually a wvd filename
|
||||
```
|
||||
|
||||
[pywidevine]: https://github.com/rlaphoenix/pywidevine
|
||||
|
||||
## serve (dict)
|
||||
|
||||
Configuration data for pywidevine's serve functionality run through unshackle.
|
||||
This effectively allows you to run `unshackle serve` to start serving pywidevine Serve-compliant CDMs right from your
|
||||
local widevine device files.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
users:
|
||||
secret_key_for_jane: # 32bit hex recommended, case-sensitive
|
||||
devices: # list of allowed devices for this user
|
||||
- generic_nexus_4464_l3
|
||||
username: jane # only for internal logging, users will not see this name
|
||||
secret_key_for_james:
|
||||
devices:
|
||||
- generic_nexus_4464_l3
|
||||
username: james
|
||||
secret_key_for_john:
|
||||
devices:
|
||||
- generic_nexus_4464_l3
|
||||
username: john
|
||||
# devices can be manually specified by path if you don't want to add it to
|
||||
# unshackle's WVDs directory for whatever reason
|
||||
# devices:
|
||||
# - 'C:\Users\john\Devices\test_devices_001.wvd'
|
||||
```
|
||||
|
||||
## services (dict)
|
||||
|
||||
Configuration data for each Service. The Service will have the data within this section merged into the `config.yaml`
|
||||
before provided to the Service class.
|
||||
|
||||
Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs,
|
||||
device attributes, and so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for
|
||||
any sensitive configuration data.
|
||||
|
||||
The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or
|
||||
a dictionary.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
NOW:
|
||||
client:
|
||||
auth_scheme: MESSO
|
||||
# ... more sensitive data
|
||||
```
|
||||
|
||||
## tag (str)
|
||||
|
||||
Group or Username to postfix to the end of all download filenames following a dash.
|
||||
For example, `tag: "J0HN"` will have `-J0HN` at the end of all download filenames.
|
||||
|
||||
## tmdb_api_key (str)
|
||||
|
||||
API key for The Movie Database (TMDB). This is used for tagging downloaded files with TMDB,
|
||||
IMDB and TVDB identifiers. Leave empty to disable automatic lookups.
|
||||
|
||||
To obtain a TMDB API key:
|
||||
|
||||
1. Create an account at https://www.themoviedb.org/
|
||||
2. Go to https://www.themoviedb.org/settings/api to register for API access
|
||||
3. Fill out the API application form with your project details
|
||||
4. Once approved, you'll receive your API key
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
tmdb_api_key: cf66bf1895dkca5361ada3bebb84eb9a
|
||||
```
|
||||
|
||||
**Note**: Keep your API key secure and do not share it publicly. This key is used by the core/utils/tags.py module to fetch metadata from TMDB for proper file tagging.
|
||||
45
CONTRIBUTING.md
Normal file
45
CONTRIBUTING.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Development
|
||||
|
||||
This project is managed using [UV](https://github.com/astral-sh/uv), a fast Python package and project manager.
|
||||
Install the latest version of UV before continuing. Development currently requires Python 3.9+.
|
||||
|
||||
## Set up
|
||||
|
||||
Starting from Zero? Not sure where to begin? Here's steps on setting up this Python project using UV. Note that
|
||||
UV installation instructions should be followed from the UV Docs: https://docs.astral.sh/uv/getting-started/installation/
|
||||
|
||||
1. Clone the Repository:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/unshackle-dl/unshackle
|
||||
cd unshackle
|
||||
```
|
||||
|
||||
2. Install the Project with UV:
|
||||
|
||||
```shell
|
||||
uv sync
|
||||
```
|
||||
|
||||
This creates a Virtual environment and then installs all project dependencies and executables into the Virtual
|
||||
environment. Your System Python environment is not affected at all.
|
||||
|
||||
3. Run commands in the Virtual environment:
|
||||
|
||||
```shell
|
||||
uv run unshackle
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
- UV automatically manages the virtual environment for you - no need to manually activate it
|
||||
- You can use `uv run` to prefix any command you wish to run under the Virtual environment
|
||||
- For example: `uv run unshackle --help` to run the main application
|
||||
- JetBrains PyCharm and Visual Studio Code will automatically detect the UV-managed virtual environment
|
||||
- For more information, see: https://docs.astral.sh/uv/concepts/projects/
|
||||
|
||||
4. Install Pre-commit tooling to ensure safe and quality commits:
|
||||
|
||||
```shell
|
||||
uv run pre-commit install
|
||||
```
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
835
OLD-CHANGELOG.md
Normal file
835
OLD-CHANGELOG.md
Normal file
@@ -0,0 +1,835 @@
|
||||
# Changelog
|
||||
|
||||
This is Devine's Original Changelog, kept this here for historical reasons.
|
||||
|
||||
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).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Support for Python 3.12.
|
||||
- Audio track's Codec Enum now has [FLAC](https://en.wikipedia.org/wiki/FLAC) defined.
|
||||
- The Downloader to use can now be set in the config under the [downloader key](CONFIG.md#downloader-str).
|
||||
- New Multi-Threaded Downloader, `requests`, that makes HTTP(S) calls using [Python-requests](https://requests.readthedocs.io).
|
||||
- New Multi-Threaded Downloader, `curl_impersonate`, that makes HTTP(S) calls using [Curl-Impersonate](https://github.com/yifeikong/curl-impersonate) via [Curl_CFFI](https://github.com/yifeikong/curl_cffi).
|
||||
- HLS manifests specifying a Byte range value without starting offsets are now supported.
|
||||
- HLS segments that use `EXT-X-DISCONTINUITY` are now supported.
|
||||
- DASH manifests with SegmentBase or only BaseURL are now supported.
|
||||
- Subtitle tracks from DASH manifests now automatically marked as SDH if `urn:tva:metadata:cs:AudioPurposeCS:2007 = 2`.
|
||||
- The `--audio-only/--subs-only/--chapters-only` flags can now be used simultaneously. For example, `--subs-only`
|
||||
with `--chapters-only` will get just Subtitles and Chapters.
|
||||
- Added `--video-only` flag, which can also still be simultaneously used with the only "only" flags. Using all four
|
||||
of these flags will have the same effect as not using any of them.
|
||||
- Added `--no-proxy` flag, disabling all uses of proxies, even if `--proxy` is set.
|
||||
- Added `--sub-format` option, which sets the wanted output subtitle format, defaulting to SubRip (SRT).
|
||||
- Added `Subtitle.reverse_rtl()` method to use SubtitleEdit's `/ReverseRtlStartEnd` functionality.
|
||||
- Added `Subtitle.convert()` method to convert the loaded Subtitle to another format. Note that you cannot convert to
|
||||
fTTML or fVTT, but you can convert from them. SubtitleEdit will be used in precedence over pycaption if available.
|
||||
Converting to SubStationAlphav4 requires SubtitleEdit, but you may want to manually alter the Canvas resolution after
|
||||
the download.
|
||||
- Added support for SubRip (SRT) format subtitles in `Subtitle.parse()` via pycaption.
|
||||
- Added `API` Vault Client aiming for a RESTful like API.
|
||||
- Added `Chapters` Class to hold the new reworked `Chapter` objects, automatically handling stuff like order of the
|
||||
Chapters, Chapter numbers, loading from a chapter file or string, and saving to a chapter file or string.
|
||||
- Added new `chapter_fallback_name` config option allowing you to set a Chapter Name Template used when muxing Chapters
|
||||
into an MKV Container with MKVMerge. Do note, it defaults to no Chapter Fallback Name at all, but MKVMerge will force
|
||||
`Chapter {i:02}` at least for me on Windows with the program language set to English. You may want to instead use
|
||||
`Chapter {j:02}` which will do `Chapter 01, Intro, Chapter 02` instead of `Chapter 01, Intro, Chapter 03` (an Intro
|
||||
is not a Chapter of story, but it is the 2nd Chapter marker, so It's up to you how you want to interpret it).
|
||||
- Added new `Track.OnSegmentDownloaded` Event, called any time one of the Track's segments were downloaded.
|
||||
- Added new `Subtitle.OnConverted` Event, called any time that Subtitle is converted.
|
||||
- Implemented `__add__` method to `Tracks` class, allowing you to add to the first Tracks object. For example, making
|
||||
it handy to merge HLS video tracks with DASH tracks, `tracks = dash_tracks + hls_tracks.videos`, or for iterating:
|
||||
`for track in dash.videos + hls.videos: ...`.
|
||||
- Added new utility `get_free_port()` to get a free local port to use, though it may be taken by the time it's used.
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved from my forked release of pymp4 (`rlaphoenix-pymp4`) back to the original `pymp4` release as it is
|
||||
now up-to-date with some of my needed fixes.
|
||||
- The DASH manifest is now stored in the Track `url` property to be reused by `DASH.download_track()`.
|
||||
- Encrypted DASH streams are now downloaded in full and then decrypted, instead of downloading and decrypting
|
||||
each individual segment. Unlike HLS, DASH cannot dynamically switch out the DRM/Protection information.
|
||||
This brings both CPU and Disk IOPS improvements, as well as fixing rare weird decryption anomalies like broken
|
||||
or odd timestamps, decryption failures, or broken a/v continuity.
|
||||
- When a track is being decrypted, it now displays "Decrypting" and afterward "Decrypted" in place of the download
|
||||
speed.
|
||||
- When a track finishes downloaded, it now displays "Downloaded" in place of the download speed.
|
||||
- When licensing is needed and fails, the track will display "FAILED" in place of the download speed. The track
|
||||
download will cancel and all other track downloads will be skipped/cancelled; downloading will end.
|
||||
- The fancy smart quotes (`“` and `”`) are now stripped from filenames.
|
||||
- All available services are now listed if you provide an invalid service tag/alias.
|
||||
- If a WVD file fails to load and looks to be in the older unsupported v1 format, then instructions on migrating to
|
||||
v2 will be displayed.
|
||||
- If Shaka-Packager prints an error (i.e., `:ERROR:` log message) it will now raise a `subprocess.CalledProcessError`
|
||||
exception, even if the process return code is 0.
|
||||
- The Video classes' Primaries, Transfer, and Matrix classes had changes to their enum names to better represent their
|
||||
values and uses. See the changed names in the [commit](https://github.com/unshackle-dl/unshackle/commit/c159672181ee3bd07b06612f256fa8590d61795c).
|
||||
- SubRip (SRT) Subtitles no longer have the `MULTI-LANGUAGE SRT` header forcefully removed. The root cause of the error
|
||||
was identified and fixed in this release.
|
||||
- Since `Range.Transfer.SDR_BT_601_625 = 5` has been removed, `Range.from_cicp()` now internally remaps CICP transfer
|
||||
values of `5` to `6` (which is now `Range.Transfer.BT_601 = 6`).
|
||||
- Referer and User-Agent Header values passed to the aria2(c) downloader is now set via the dedicated `--referer` and
|
||||
`--user-agent` options respectively, instead of `--header`.
|
||||
- The aria2(c) `-j`, `-x`, and `-s` option values can now be set by the config under the `aria2c` key in the options'
|
||||
full names.
|
||||
- The aria2(c) `-x`, and `-s` option values now use aria2(c)'s own default values for them instead of `16`. The `j`
|
||||
option value defaults to ThreadPoolExecutor's algorithm of `min(32,(cpu_count+4))`.
|
||||
- The download progress bar now states `LICENSING` on the speed text when licensing DRM, and `LICENSED` once finished.
|
||||
- The download progress bar now states `CANCELLING`/`CANCELLED` on the speed text when cancelling downloads. This is to
|
||||
make it more clear that it didn't just stop, but stopped as it was cancelled.
|
||||
- The download cancel/skip events were moved to `constants.py` so it can be used across the codebase easier without
|
||||
argument drilling. `DL_POOL_STOP` was renamed to `DOWNLOAD_CANCELLED` and `DL_POOL_SKIP` to `DOWNLOAD_LICENCE_ONLY`.
|
||||
- The Cookie header is now calculated for each URL passed to the aria2(c) downloader based on the URL. Instead of
|
||||
passing every single cookie, which could have two cookies with the same name aimed for different host names, we now
|
||||
pass only cookies intended for the URL.
|
||||
- The aria2(c) process no longer prints output to the terminal directly. unshackle now only prints contents of the
|
||||
captured log messages to the terminal. This allows filtering out of errors and warnings that isn't a problem.
|
||||
- DASH and HLS no longer download segments silencing errors on all but the last retry as the downloader rework makes
|
||||
this unnecessary. The errors will only be printed on the final retry regardless.
|
||||
- `Track.repackage()` now saves as `{name}_repack.{ext}` instead of `{name}.repack.{ext}`.
|
||||
- `Video.change_color_range()` now saves as `{name}_{limited|full}_range.{ext}` instead of `{name}.range{0|1}.{ext}`.
|
||||
- `Widevine.decrypt()` now saves as `{name}_decrypted.{ext}` instead of `{name}.decrypted.{ext}`.
|
||||
- Files starting with the save path's name and using the save path's extension, but not the save path, are no longer
|
||||
deleted on download finish/stop/failure.
|
||||
- The output container format is now explicitly specified as `MP4` when calling `shaka-packager`.
|
||||
- The default downloader is now `requests` instead of `aria2c` to reduce required external dependencies.
|
||||
- Reworked the `Chapter` class to only hold a timestamp and name value with an ID automatically generated as a CRC32 of
|
||||
the Chapter representation.
|
||||
- The `--group` option has been renamed to `--tag`.
|
||||
- The config file is now read from three more locations in the following order:
|
||||
1) The unshackle Namespace Folder (e.g., `%appdata%/Python/Python311/site-packages/unshackle/unshackle.yaml`).
|
||||
2) The Parent Folder to the unshackle Namespace Folder (e.g., `%appdata%/Python/Python311/site-packages/unshackle.yaml`).
|
||||
3) The AppDirs User Config Folder (e.g., `%localappdata%/unshackle/unshackle.yaml`).
|
||||
Location 2 allows having a config at the root of a portable folder.
|
||||
- An empty config file is no longer created when no config file is found.
|
||||
- You can now set a default cookie file for a Service, [see README](README.md#cookies--credentials).
|
||||
- You can now set a default credential for a Service, [see config](CONFIG.md#credentials-dictstr-strlistdict).
|
||||
- Services are now auth-less by default and the error for not having at least a cookie or credential is removed.
|
||||
Cookies/Credentials will only be loaded if a default one for the service is available, or if you use `-p/--profile`
|
||||
and the profile exists.
|
||||
- Subtitles when converting to SubRip (SRT) via SubtitleEdit will now use the `/ConvertColorsToDialog` option.
|
||||
- HLS segments are now merged by discontinuity instead of all at once. The merged discontinuities are then finally
|
||||
merged to one file using `ffmpeg`. Doing the final merge by byte concatenation did not work for some playlists.
|
||||
- The Track is no longer passed through Event Callables. If you are able to set a function on an Even Callable, then
|
||||
you should have access to the track reference to call it directly if needed.
|
||||
- The Track.OnDecrypted event callable is now passed the DRM and Segment objects used to Decrypt. The segment object is
|
||||
only passed from HLS downloads.
|
||||
- The Track.OnDownloaded event callable is now called BEFORE decryption, right after downloading, not after decryption.
|
||||
- All generated Track ID values across the codebase has moved from md5 to crc32 values as code processors complain
|
||||
about its use surrounding security, and it's length is too large for our use case anyway.
|
||||
- HLS segments are now downloaded multi-threaded first and then processed in sequence thereafter.
|
||||
- HLS segments are no longer decrypted one-by-one, requiring a lot of shaka-packager processes to run and close.
|
||||
They now merged and decrypt in groups based on their EXT-X-KEY, before being merged per discontinuity.
|
||||
- The DASH and HLS downloaders now pass multiple URLs to the downloader instead of one-by-one, heavily increasing speed
|
||||
and reliability as connections are kept alive and re-used.
|
||||
- Downloaders now yield back progress information in the same convention used by `rich`'s `Progress.update()` method.
|
||||
DASH and HLS now pass the yielded information to their progress callable instead of passing the progress callable to
|
||||
the downloader.
|
||||
- The aria2(c) downloader now uses the aria2(c) JSON-RPC interface to query for download progress updates instead of
|
||||
parsing the stdout data in an extremely hacky way.
|
||||
- The aria2(c) downloader now re-routes non-HTTP proxies via `pproxy` by a subprocess instead of the now-removed
|
||||
`start_pproxy` utility. This way has proven to be easier, more reliable, and prevents pproxy from messing with rich's
|
||||
terminal output in strange ways.
|
||||
- All downloader function's have an altered signature but ultimately similar. `uri` to `urls`, `out` (path) was removed,
|
||||
we now calculate the save path by passing an `output_dir` and `filename`. The `silent`, `segmented`, and `progress`
|
||||
parameters were completely removed.
|
||||
- All downloader `urls` can now be a string or a dictionary containing extra URL-specific options to use like
|
||||
URL-specific headers. It can also be a list of the two types of URLs to downloading multi-threaded.
|
||||
- All downloader `filenames` can be a static string, or a filename string template with a few variables to use. The
|
||||
template system used is f-string, e.g., `"file_{i:03}{ext}"` (ext starts with `.` if there's an extension).
|
||||
- DASH now updates the progress bar when merging segments.
|
||||
- The `Widevine.decrypt()` method now also searches for shaka-packager as just `packager` as it is the default build
|
||||
name. (#74)
|
||||
|
||||
### Removed
|
||||
|
||||
- The `unshackle auth` command and sub-commands due to lack of support, risk of data, and general quirks with it.
|
||||
- Removed `profiles` config, you must now specify which profile you wish to use each time with `-p/--profile`. If you
|
||||
use a specific profile a lot more than others, you should make it the default.
|
||||
- The `saldl` downloader has been removed as their binary distribution is whack and development has seemed to stall.
|
||||
It was only used as an alternative to what was at the time the only downloader, aria2(c), as it did not support any
|
||||
form of Byte Range, but `saldl` did, which was crucial for resuming extremely large downloads or complex playlists.
|
||||
However, now we have the requests downloader which does support the Range header.
|
||||
- The `Track.needs_proxy` property was removed for a few design architectural reasons.
|
||||
1) Design-wise it isn't valid to have --proxy (or via config/otherwise) set a proxy, then unpredictably have it
|
||||
bypassed or disabled. If I specify `--proxy 127.0.0.1:8080`, I would expect it to use that proxy for all
|
||||
communication indefinitely, not switch in and out depending on the track or service.
|
||||
2) With reason 1, it's also a security problem. The only reason I implemented it in the first place was so I could
|
||||
download faster on my home connection. This means I would authenticate and call APIs under a proxy, then suddenly
|
||||
download manifests and segments e.t.c under my home connection. A competent service could see that as an indicator
|
||||
of bad play and flag you.
|
||||
3) Maintaining this setup across the codebase is extremely annoying, especially because of how proxies are setup/used
|
||||
by Requests in the Session. There's no way to tell a request session to temporarily disable the proxy and turn it
|
||||
back on later, without having to get the proxy from the session (in an annoying way) store it, then remove it,
|
||||
make the calls, then assuming your still in the same function you can add it back. If you're not in the same
|
||||
function, well, time for some spaghetti code.
|
||||
- The `Range.Transfer.SDR_BT_601_625 = 5` key and value has been removed as I cannot find any official source to verify
|
||||
it as the correct use. However, usually a `transfer` value of `5` would be PAL SD material so it better matches `6`,
|
||||
which is (now named) `Range.Transfer.BT_601 = 6`. If you have something specifying transfer=5, just remap it to 6.
|
||||
- The warning log `There's no ... Audio Tracks, likely part of an invariant playlist, continuing...` message has been
|
||||
removed. So long as your playlist is expecting no audio tracks, or the audio is part of the video transport, then
|
||||
this wouldn't be a problem whatsoever. Therefore, having it log this annoying warning all the time is pointless.
|
||||
- The `--min-split-size` argument to the aria2(c) downloader as it was only used to disable splitting on
|
||||
segmented downloads, but the newer downloader system wouldn't really need or want this to be done. If aria2 has
|
||||
decided based on its other settings to have split a segment file, then it likely would benefit from doing so.
|
||||
- The `--remote-time` argument from the aria2(c) downloader as it may need to do a GET and a HEAD request to
|
||||
get the remote time information, slowing the download down. We don't need this information anyway as it will likely
|
||||
be repacked with `ffmpeg` or multiplexed with `mkvmerge`, discarding/losing that information.
|
||||
- DASH and HLS's 5-attempt retry loop as the downloaders will retry for us.
|
||||
- The `start_pproxy` utility has been removed as all uses of it now call `pproxy` via subprocess instead.
|
||||
- The `LANGUAGE_MUX_MAP` constant and it's usage has been removed as it is no longer necessary as of MKVToolNix v54.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Uses of `__ALL__` with Class objects have been correct to `__all__` with string objects, following PEP8.
|
||||
- Fixed value of URL passed to `Track.get_key_id()` as it was a tuple rather than the URL string.
|
||||
- The `--skip-dl` flag now works again after breaking in v[1.3.0].
|
||||
- Move WVD file to correct location on new installations in the `wvd add` command.
|
||||
- Cookie data is now passed to downloaders and use URLs based on the URI it will be used for, just like a browser.
|
||||
- Failure to get FPS in DASH when SegmentBase isn't used.
|
||||
- An error message is now returned if a WVD file fails to load instead of raising an exception.
|
||||
- Track language information within M3U playlists are now validated with langcodes before use. Some manifests use the
|
||||
property for arbitrary data that their apps/players use for their own purposes.
|
||||
- Attempt to fix non-UTF-8 and mixed-encoding Subtitle downloads by automatically converting to UTF-8. (#43)
|
||||
Decoding is attempted in the following order: UTF-8, CP-1252, then finally chardet detection. If it's neither UTF-8
|
||||
nor CP-1252 and chardet could not detect the encoding, then it is left as-is. Conversion is done per-segment if the
|
||||
Subtitle is segmented, unless it's the fVTT or fTTML formats which are binary.
|
||||
- Chapter Character Encoding is now explicitly set to UTF-8 when muxing to an MKV container as Windows seems to default
|
||||
to latin1 or something, breaking Chapter names with any sort of special character within.
|
||||
- Subtitle passed through SubtitleEdit now explicitly use UTF-8 character encoding as it usually defaulted to UTF-8
|
||||
with Byte Order Marks (aka UTF-8-SIG/UTF-8-BOM).
|
||||
- Subtitles passed through SubtitleEdit now use the same output format as the subtitle being processed instead of SRT.
|
||||
- Fixed rare infinite loop when the Server hosting the init/header data/segment file responds with a `Content-Length`
|
||||
header with a value of `0` or smaller.
|
||||
- Removed empty caption lists/languages when parsing Subtitles with `Subtitle.parse()`. This stopped conversions to SRT
|
||||
containing the `MULTI-LANGUAGE SRT` header when there was multiple caption lists, even though only one of them
|
||||
actually contained captions.
|
||||
- Text-based Subtitle formats now try to automatically convert to UTF-8 when run through `Subtitle.parse()`.
|
||||
- Text-based Subtitle formats now have `‎` and `‏` HTML entities unescaped post-download as some rendering
|
||||
libraries seems to not decode them for us. SubtitleEdit also has problems with `/ReverseRtlStartEnd` unless it's
|
||||
already decoded.
|
||||
- Fixed two concatenation errors surrounding DASH's BaseURL, sourceURL, and media values that start with or use `../`.
|
||||
- Fixed the number values in the `Newly added to x/y Vaults` log, which now states `Cached n Key(s) to x/y Vaults`.
|
||||
- File write handler now flushes after appending a new segment to the final save path or checkpoint file, reducing
|
||||
memory usage by quite a bit in some scenarios.
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [Shivelight](https://github.com/Shivelight)
|
||||
|
||||
## [2.2.0] - 2023-04-23
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Since `-q/--quality` has been reworked to support specifying multiple qualities, the type of this value is
|
||||
no longer `None|int`. It is now `list[int]` and the list may be empty. It is no longer ever a `None` value.
|
||||
|
||||
Please make sure any Service code that uses `quality` via `ctx.parent.params` reflects this change. You may
|
||||
need to go from an `if quality: ...` to `for res in quality: ...`, or such. You may still use `if quality`
|
||||
to check if it has 1 or more resolution specified, but make sure that the code within that if tree supports
|
||||
more than 1 value in the `quality` variable, which is now a list. Note that the list will always be in
|
||||
descending order regardless of how the user specified them.
|
||||
|
||||
### Added
|
||||
|
||||
- Added the ability to specify and download multiple resolutions with `-q/--quality`. E.g., `-q 1080p,720p`.
|
||||
- Added support for DASH manifests that use SegmentList with range values on the Initialization definition (#47).
|
||||
- Added a check for `uuid` mp4 boxes containing `tenc` box data when getting the Track's Key ID to improve
|
||||
chances of finding a Key ID.
|
||||
|
||||
### Changed
|
||||
|
||||
- The download path is no longer printed after each download. The simple reason is it felt unnecessary.
|
||||
It filled up a fair amount of vertical space for information you should already know.
|
||||
- The logs after a download finishes has been split into two logs. One after the actual downloading process
|
||||
and the other after the multiplexing process. The downloading process has its own timer as well, so you can
|
||||
see how long the downloads itself took.
|
||||
- I've switched from using the official pymp4 (for now) with my fork. At the time this change was made the
|
||||
original bearypig pymp4 repo was stagnant and the PyPI releases were old. I forked it, added some fixes
|
||||
by TrueDread and released my own update to PyPI, so it's no longer outdated. This was needed for some
|
||||
mp4 box parsing fixes. Since then the original repo is no longer stagnant, and a new release was made on
|
||||
PyPI. However, my repo still has some of TrueDread's fixes that is not yet on the original repository nor
|
||||
on PyPI.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `with_resolution` method in the Tracks class. It has been replaced with `by_resolutions`. The
|
||||
new replacement method supports getting all or n amount of tracks by resolution instead of the original
|
||||
always getting all tracks by resolution.
|
||||
- Removed the `select_per_language` method in the Tracks class. It has been replaced with `by_language`. The
|
||||
new replacement method supports getting all or n amount of tracks by language instead of the original only
|
||||
able to get one track by language. It now defaults to getting all tracks by language.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented some duplicate Widevine tree logs under specific edge-cases.
|
||||
- The Subtitle parse method no longer absorbs the syntax error message.
|
||||
- Replaced all negative size values with 0 on TTML subtitles as a negative value would cause syntax errors.
|
||||
- Fixed crash during decryption when shaka-packager skips decryption of a segment as it had no actual data and
|
||||
was just headers.
|
||||
- Fixed CCExtractor crash in some scenarios by repacking the video stream prior to extraction.
|
||||
- Fixed rare crash when calculating download speed of DASH and HLS downloads where a segment immediately finished
|
||||
after the previous segment. This seemed to only happen on the very last segment in rare situations.
|
||||
- Fixed some failures parsing `tenc` mp4 boxes when obtaining the track's Key ID by using my own fork of pymp4
|
||||
with up-to-date code and further fixes.
|
||||
- Fixed crashes when parsing some `tenc` mp4 boxes by simply skipping `tenc` boxes that fail to parse. This happens
|
||||
because some services seem to mix up the data of the `tenc` box with that of another type of box.
|
||||
- Fixed using invalid `tenc` boxes by skipping ones with a version number greater than 1.
|
||||
|
||||
## [2.1.0] - 2023-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- The Track get_init_segment method has been re-written to be more controllable. A specific Byte-range, URL, and
|
||||
maximum size can now be specified. A manually specified URL will override the Track's current URL. The Byte-range
|
||||
will override the fallback value of `0-20000` (where 20000 is the default `maximum_size`). It now also checks if the
|
||||
server supports Byte-range, or it will otherwise stream the response. It also tries to get the file size length and
|
||||
uses that instead of `maximum_size` unless it's bigger than `maximum_size`.
|
||||
- Added new `get_key_id` method to Track to probe the track for a track-specific Encryption Key ID. This is similar to
|
||||
Widevine's `from_track` method but ignores all `pssh` boxes and manifest information as the information within those
|
||||
could be for a wider range of tracks or not for that track at all.
|
||||
- Added a 5-attempt retry system to DASH and HLS downloads. URL downloads only uses aria2(c)'s built in retry system
|
||||
which has the same amount of tries and same delay between attempts. Any errors emitted when downloading segments will
|
||||
not be printed to console unless it occurred on the last attempt.
|
||||
- Added a fallback way to obtain language information by taking it from the representation ID value, which may have the
|
||||
language code within it. E.g., `audio_en=128000` would be an English audio track at 128kb/s. We now take the `en`
|
||||
from that ID where possible.
|
||||
- Added support for 13-char JS-style timestamp values to the Cacher system.
|
||||
- Improved Forced Subtitle recognition by checking for both `forced-subtitle` and `forced_subtitle` (#43).
|
||||
|
||||
### Changed
|
||||
|
||||
- The `*` symbol is no longer spaced after the Widevine `KID:KEY` when denoting that it is for this specific PSSH.
|
||||
This reduces wasted vertical space.
|
||||
- The "aria2 will resume download if the transfer is restarted" logs that occur when aria2(c) handles the CTRL+C break,
|
||||
and "If there are any errors, then see the log file" logs are now ignored and no longer logged to the console.
|
||||
- DASH tracks will no longer prepare and license DRM unless it's just about to download. This is to reduce unnecessary
|
||||
preparation of DRM if the track had been converted to a URL download.
|
||||
- For a fix listed below, we now use a fork of https://github.com/globocom/m3u8 that fixes a glaring problem with the
|
||||
EXT-X-KEY parsing system. See <https://github.com/globocom/m3u8/pull/313>.
|
||||
- The return code when mkvmerge returns an error is now logged with the error message.
|
||||
- SubtitleEdit has been silenced when using it for SDH stripping.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed URL joining and Base URL calculations on DASH manifests that use multiple Base URL values.
|
||||
- URL downloads will now store the chosen DRM before preparing and licensing with the DRM.
|
||||
- URL downloads will now prepare and license with the DRM if the Track has pre-existing DRM information. Previously it
|
||||
would only prepare and license DRM if it did not pre-emptively have DRM information before downloading.
|
||||
- The `*` symbol that indicates that the KID:KEY is for the track being downloaded now uses the new `get_key_id` method
|
||||
of the track for a more accurate reading.
|
||||
- License check now ensures if a KEY was returned for the Track instead of all KIDs of the Track's PSSH. This prevents
|
||||
an issue where the PSSH may have Key IDs for a 720p and 1080p track, yet only a KEY for the 720p track was returned.
|
||||
It would have then raised an error and stopped the download, even though you are downloading the 720p track and not
|
||||
the 1080p track, therefore the error was irrelevant.
|
||||
- Unnecessary duplicate license calls are now prevented in some scenarios where `--cdm-only` is used.
|
||||
- Fixed accuracy and speed of preparing and licensing DRM on HLS manifests where multiple EXT-X-KEY definitions appear
|
||||
in the manifest throughout the file. Using <https://github.com/globocom/m3u8/pull/313> we can now accurately get a
|
||||
list of EXT-X-KEYs mapped to each segment. This is a game changer for HLS manifests that use unique keys for every
|
||||
single (or most) segments as it would have otherwised needed to initialize (and possibly do network requests) for
|
||||
100s of EXT-X-KEY information, per segment. This caused downloads of HLS manifests that used a unique key per segment
|
||||
to slow to a binding crawl, and still not even decrypt correctly as it wouldn't be able to map the correct initialized
|
||||
key to the correct segment.
|
||||
- Fixed a regression that incorrectly implemented the OnMultiplex event for Audio and Subtitle tracks causing them to
|
||||
never trigger. It would instead accidentally have trigger the last Video track's OnMultiplex event instead of the
|
||||
Audio or Subtitle's event.
|
||||
- The above fix also fixed the automatic SDH stripping subtitle. Any automatically created SDH->non-SDH subtitle from
|
||||
prior downloads would not have actually had SDH captions stripped, it would instead be a duplicate subtitle.
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [Hollander-1908](https://github.com/Hollander-1908)
|
||||
|
||||
## [2.0.1] - 2023-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- Re-added logging support for shaka-packager on errors and warnings. Do note that INFO logs and the 'Insufficient bits
|
||||
in bitstream for given AVC profile' warning logs are ignored and never printed.
|
||||
- Added new exceptions to the Widevine DRM class, `CEKNotFound` and `EmptyLicense`.
|
||||
- Added support for Byte-ranges on HLS init maps.
|
||||
|
||||
### Changed
|
||||
|
||||
- Now lists the full 'Episode #' text when listing episode titles without an episode name.
|
||||
- Subprocess exceptions from a download worker no longer prints a traceback. It now only logs the return code. This is
|
||||
because all subprocess errors during a download is now logged, therefore the full traceback is no longer necessary.
|
||||
- Aria2(c) no longer pre-allocates file space if segmented. This is to reduce generally unnecessary upfront I/O usage.
|
||||
- The Widevine DRM class's `get_content_keys` method now raises the new `CEKNotFound` and `EmptyLicense` exceptions not
|
||||
`ValueError` exceptions.
|
||||
- The prepare_drm code now raises exceptions where needed instead of `sys.exit(1)`. Callees do not need to make any
|
||||
changes. The exception should continue to go up the call stack and get handled by the `dl` command.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed regression that broke support for pproxy. Do note that while pproxy has wheel's for Python 3.11+, it seems to
|
||||
be broken. I recommend using Python 3.10 or older for now. See <https://github.com/qwj/python-proxy/issues/161>.
|
||||
- Fixed regression and now store the chosen DRM object back to the track.drm field. Please note that using the track
|
||||
DRM field in Service code is not recommended, but for some services it's simply required.
|
||||
- Fixed regression since v1.4.0 where the byte-range calculation was actually slightly off one on the right-side range.
|
||||
This was a one-indexed vs. zero-indexed problem. Please note that this could have affected the integrity of HLS
|
||||
downloads if they used EXT-X-BYTERANGE.
|
||||
- Fixed possible soft-lock in HLS if the Queue for previous segment key and init data gets stuck in an empty state over
|
||||
an exception in a download thread. E.g., if a thread takes the previous segment key, throws an exception, and did not
|
||||
get the chance to give it back for the next thread.
|
||||
- The prepare_drm function now handles unexpected exceptions raised in the Service's license method. This code would of
|
||||
otherwise been absorbed and the download would have soft-locked.
|
||||
- Prevented a double-licensing call race-condition on HLS tracks by using a threading lock when preparing DRM
|
||||
information. This is not required in DASH, as it prepares DRM on the main thread, once, not per-segment.
|
||||
- Fixed printing of aria2(c) logs when redirecting progress information to rich progress bars.
|
||||
- Explicitly mark DASH and HLS aria2(c) downloads as segmented.
|
||||
- Fixed listing of episode titles without an episode name.
|
||||
- Fixed centering of the project URL in the ASCII banner.
|
||||
- Removed the accidental double-newline after the ASCII banner.
|
||||
|
||||
## [2.0.0] - 2023-03-01
|
||||
|
||||
This release brings a huge change to the fundamentals of unshackle's logging, UI, and UX.
|
||||
|
||||
### Added
|
||||
|
||||
- Add new dependency [rich](https://github.com/Textualize/rich) for advanced color and logging capabilities.
|
||||
- Set rich console output color scheme to the [Catppuccin Mocha](https://github.com/catppuccin/palette) theme.
|
||||
- Add full download cancellation support by using CTRL+C. Track downloads will now be marked as STOPPED if you press
|
||||
CTRL+C to stop the download, or FAILED if any unexpected exception occurs during a download. The track will be marked
|
||||
as SKIPPED if the download stopped or failed before it got a chance to begin. It will print a download cancelled
|
||||
message if downloading was stopped, or a download error message if downloading failed. It will print the first
|
||||
download error traceback with rich before stopping.
|
||||
- Downloads will now automatically cancel if any track or segment download fails.
|
||||
- Implement sub-commands `add` and `delete` to the `wvd` command for adding and deleting WVD (Widevine Device) files to
|
||||
and from the configured WVDs directory (#31).
|
||||
- Add new config option to disable the forced background color. You may want to disable the purple background if you're
|
||||
terminal isn't able to apply it correctly, or you prefer to use your own terminal's background color.
|
||||
- Create `ComfyConsole`, `ComfyLogRenderer`, and `ComfyRichHandler`. These are hacky classes to implement padding to
|
||||
the left and right of all rich console output. This gives unshackle a comfortable and freeing look-and-feel.
|
||||
- An ASCII banner is now displayed at the start of software execution with the version number.
|
||||
- Add rich status output to various parts of the download process. It's also used when checking GEOFENCE within the
|
||||
base Service class. I encourage you to follow similar procedures where possible in Service code. This will result in
|
||||
cleaner log output, and overall less logs being made when finished.
|
||||
- Add three rich horizontal rules to separate logs during the download process. The Service used, the Title received
|
||||
from `get_titles()`, and then the Title being downloaded. This helps identify which logs are part of which process.
|
||||
- Add new `tree` methods to `Series`, `Movies`, and `Album` classes to list items within the objects with Rich Tree.
|
||||
This allows for more rich console output when displaying E.g., Seasons and Episodes within a Series, or Songs within
|
||||
an Album.
|
||||
- Add new `tree` method to the `Tracks` class to list the tracks received from `get_tracks()` with Rich Tree. Similar
|
||||
to the change just above, this allows for more rich console output. It has replaced the `Tracks.print()` method.
|
||||
- Add a rich progress bar to the track multiplexing operation.
|
||||
- Add a log when a download finishes, how long it took, and where the final muxed file was moved to.
|
||||
- Add a new track event, `OnMultiplex`. This event is run prior to multiplexing the finalized track data together. Use
|
||||
this to run code once a track has finished downloading and all the post-download operations.
|
||||
- Add support for mapping Netflix profiles beginning with `h264` to AVC. E.g., the new -QC profiles.
|
||||
- Download progress bars now display the download speed. It displays in decimal (^1024) size. E.g., MB/s.
|
||||
- If a download stops or fails, any residual file that may have been downloaded in an incomplete OR complete state will
|
||||
now be deleted. Download continuation is not yet supported, and this will help to reduce leftover stale files.
|
||||
|
||||
### Changed
|
||||
|
||||
- The logging base config now has `ComfyRichHandler` as its log handler for automatic rich console output when using
|
||||
the logging system.
|
||||
- The standard `traceback` module has been overridden with `rich.traceback` for styled traceback output.
|
||||
- Only the rich console output is now saved when using `--log`.
|
||||
- All `tqdm` progress bars have been replaced with rich progress bars. The rich progress bars are now displayed under
|
||||
each track tree.
|
||||
- The titles are now only listed if `--list-titles` is used. Otherwise, only a brief explanation of what it received
|
||||
from `get_titles()` will be returned. E.g., for Series it will list how many seasons and episodes were received.
|
||||
- Similarly, all available tracks are now only listed if `--list` is used. This is to reduce unnecessary prints, and to
|
||||
separate confusion between listings of available tracks, and listings of tracks that are going to be downloaded.
|
||||
- Listing all available tracks with `--list` no longer continues execution. It now stops after the first list. If you
|
||||
want to list available tracks for a specific title, use `-w` in combination with `--list`.
|
||||
- The available tracks are now printed in a rich panel with a header denoting the tracks as such.
|
||||
- The `Series`, `Movies`, and `Album` classes now have a much more simplified string representation. They now simply
|
||||
state the overarching content within them. E.g., Series says the title and year of the TV Show.
|
||||
- The final log when all titles are processed is now a rich log and states how long the entire process took.
|
||||
- Widevine DRM license information is now printed below the tracks as a rich tree.
|
||||
- The CCExtractor process, Subtitle Conversion process, and FFmpeg Repacking process were all moved out of the track
|
||||
download function (and therefore the thread) to be done on the main thread after downloading. This improves download
|
||||
speed as the threads can close and be freed quicker for the next track to begin.
|
||||
- The CCExtractor process is now optional and will be skipped if the binary could not be found. An error is still
|
||||
logged in the cases where it would have run.
|
||||
- The execution point of the `OnDownloaded` event has been moved to directly run after the stream has been downloaded.
|
||||
It used to run after all the post-download operations finished like CCExtractor, FFmpeg Repacking, and Subtitle
|
||||
Conversion.
|
||||
- The automatic SDH-stripped subtitle track now uses the new `OnMultiplex` event instead of `OnDownloaded`. This is to
|
||||
account for the previous change as it requires the subtitle to be first converted to SubRip to support SDH-stripping.
|
||||
- Logs during downloads now appear before the downloading track list. This way it isn't constantly interrupting view of
|
||||
the progress.
|
||||
- Now running aria2(c) with normal subprocess instead of through asyncio. This removes the creation of yet another
|
||||
thread which is unnecessary as these calls would have already been under a non-main thread.
|
||||
- Moved Widevine DRM licensing calls before the download process for normal URL track downloads.
|
||||
- Segment Merging code for DASH and HLS downloads have been moved from the `dl` class to the HLS and DASH class.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove explicit dependency on `coloredlogs` and `colorama` as they are no longer used by unshackle itself.
|
||||
- Remove dependency `tqdm` as it was replaced with rich progress bars.
|
||||
- Remove now-unused logging constants like the custom log formats.
|
||||
- Remove `Tracks.print()` function as it was replaced with the new `Tracks.tree()` function.
|
||||
- Remove unnecessary sleep calls at the start of threads. This was believed to help with the download stop event check
|
||||
but that was not the case. It instead added an artificial delay with downloads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix another crash when using unshackle without a config file. It now creates the directory of the config file before
|
||||
making a new config file.
|
||||
- Set the default aria2(c) file-allocation to `prealloc` like stated in the config documentation. It uses `prealloc` as
|
||||
the default, as `falloc` is generally unsupported in most scenarios, so it's not a good default.
|
||||
- Correct the config documentation in regard to `proxies` now being called `proxy_providers`, and `basic` actually
|
||||
being a `dict` of lists, and not a `dict` of strings.
|
||||
|
||||
## [1.4.0] - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for byte-ranged HLS and DASH segments, i.e., HLS EXT-X-BYTERANGE and DASH SegmentBase. Byte-ranged
|
||||
segments will be downloaded using python-requests as aria2(c) does not support byte ranges.
|
||||
- Added support for data URI scheme in ClearKey DRM, including support for the base64 extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Increase the urllib3 connection pool max size from the default 10 to 16 * 2. This is to accommodate up to 16
|
||||
byte-ranged segment downloads while still giving enough room for a few other connections.
|
||||
- The urllib3 connection pool now blocks and waits if it's full. This removes the Connection Pool Limit warnings when
|
||||
downloading more than one byte-ranged segmented track at a time.
|
||||
- Moved `--log` from the `dl` command to the entry command to allow logging of more than just the download command.
|
||||
With this change, the logs now include the initial root logs, including the version number.
|
||||
- Disable the urllib3 InsecureRequestWarnings as these seem to occur when using HTTP+S proxies when connecting to an
|
||||
HTTPS URL. While not ideal, we can't solve this problem, and the warning logs are quite annoying.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the `byte_range` parameter from the aria2(c) downloader that was added in v1.3.0 as it turns out it doesn't
|
||||
actually work. Theoretically it should, but it seems aria2(c) doesn't honor the Range header correctly and fails.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the JOC check on HLS playlists to check if audio channels are defined first.
|
||||
- Fix decryption of AES-encrypted segments that are not pre-padded to AES-CBC boundary size (16 bytes).
|
||||
- Fix the order of segment merging on Linux machines. On Windows, the `pathlib.iterdir()` function is always in order.
|
||||
However, on Linux, or at least some machines, this was not the case.
|
||||
- Fix printing of the traceback when a download worker raises an unexpected exception.
|
||||
- Fix initial creation of the config file if none was created yet.
|
||||
|
||||
## [1.3.1] - 2023-02-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a regression where the `track.path` was only updated for `Descriptor.URL` downloads if it had DRM. This caused
|
||||
downloads of subtitles or DRM-free tracks using the `URL` descriptor to be broken (#33).
|
||||
- Fixed a regression where `title` and `track` were not passed to the Service's functions for getting Widevine Service
|
||||
Certificates and Widevine Licenses.
|
||||
- Corrected the Cookie Path that was logged when adding cookies with `unshackle auth add`.
|
||||
- The Config data is now defaulted to an empty dictionary when completely empty or non-existent. This fixes a crash if
|
||||
you try to use `unshackle auth add` without a config file.
|
||||
|
||||
## [1.3.0] - 2023-02-22
|
||||
|
||||
## Deprecated
|
||||
|
||||
- Support for Python 3.8 has been dropped. Support for Windows 7 ended in January 2020.
|
||||
- Although Python 3.8 is the last version with support for Windows 7, the decision was made to drop support because
|
||||
the number of affected users would be low.
|
||||
- You may be interested in <https://github.com/adang1345/PythonWin7>, which has newer installers with patched support.
|
||||
|
||||
### Added
|
||||
|
||||
- Segmented HLS and DASH downloads now provide useful progress information using TQDM. Previously, aria2c would print
|
||||
progress information, but it was not very useful for segmented downloads due to how the information was presented.
|
||||
- Segmented HLS and DASH downloads are now manually multi-threaded in a similar way to aria2c's `--j=16`.
|
||||
- A class-function was added to the Widevine DRM class to obtain PSSH and KID information from init data by looking for
|
||||
PSSH and TENC boxes. This is an alternative to the from_track class-function when you only have the init data and not
|
||||
a track object.
|
||||
- Aria2c now has the ability to silence progress output and provide extra arguments.
|
||||
|
||||
### Changed
|
||||
|
||||
- The downloading system for HLS and DASH has been completely reworked. It no longer downloads segments, merges them,
|
||||
and then decrypts. Instead, it now downloads and decrypts each individual segment. It dynamically switches DRM and
|
||||
Init Data per-segment where needed, fully supporting multiple EXT-X-KEY, EXT-X-MAP, and EXT-X-DISCONTINUITY tags in
|
||||
HLS. You can now download DRM-encrypted and DRM-free segments from within the same manifest, as well as manifests
|
||||
with unique DRM per-segment. None of this was possible with the old method of downloading.
|
||||
- If a HLS manifest or segment uses an EXT-X-KEY with the method of NONE, it is assumed that the manifest or segment is
|
||||
DRM-free. This behavior applies even if the manifest or segment has other EXT-X-KEY methods specified, as that would
|
||||
be a mistake in the manifest.
|
||||
- HLS now uses the proxy when loading AES-128 DRM as ClearKey objects, which is required for some services. It will
|
||||
only be used if `Track.needs_proxy` is True.
|
||||
- The Widevine and ClearKey DRM classes decrypt functions no longer ask for a track. Instead, they ask for an input
|
||||
file path to which it will decrypt. It will automatically delete the input file and put the decrypted data in its
|
||||
place.
|
||||
|
||||
### Removed
|
||||
|
||||
- The AtomicSQL utility was removed because it did not actually assist in making the SQL connections thread-safe. It
|
||||
helped, but in an almost backwards and over-thought approach.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Cacher expiration check now uses your local datetime timestamp over the UTC timestamp, which seems to have fixed
|
||||
early or late expiration if you are not at exactly UTC+00:00.
|
||||
- The cookies file path is now checked to exist if supplied with the `--cookies` argument (#30).
|
||||
- An error is now logged, and execution will end if none of the DRM for a HLS manifest or segment is supported.
|
||||
- HLS now only loads AES-128 EXT-X-KEY methods as ClearKey DRM because it currently only supports AES-128.
|
||||
- AtomicSQL was replaced with connection factory systems using thread-safe storage for SQL connections. All Vault SQL
|
||||
calls are now fully thread-safe.
|
||||
|
||||
## [1.2.0] - 2023-02-13
|
||||
|
||||
### Deprecation Warning
|
||||
|
||||
- This release marks the end of support for Python 3.8.x.
|
||||
- Although version 1.0.0 was intended to support Python 3.8.x, PyCharm failed to warn about a specific type annotation
|
||||
incompatibility. As a result, I was not aware that the support was not properly implemented.
|
||||
- This release adds full support for Python 3.8.x, but it will be the only release with such support.
|
||||
|
||||
### Added
|
||||
|
||||
- The `dl` command CLI now includes Bitrate Selection options: `-vb/--vbitrate` and `-ab/--abitrate`.
|
||||
- The `dl` command CLI now includes an Audio Channels Selection option: `-c/--channels`.
|
||||
- If a download worker fails abruptly, a full traceback will now be printed.
|
||||
- The aria2c downloader has a new parameter for downloading a specific byte range.
|
||||
|
||||
### Changed
|
||||
|
||||
- The usage of `Path.with_stem` with `Path.with_suffix` has been simplified to `Path.with_name`.
|
||||
- When printing audio track information, the assumption that the audio is `2.0ch` has been removed.
|
||||
- If audio channels were previously set as an integer value, they are no longer transformed as e.g., `6ch` and now
|
||||
follow the normal behavior of being defined as a float value, e.g., `6.0`.
|
||||
- Audio channels are now explicitly parsed as float values, therefore parsing of values such as `16/JOC` (HLS) is no
|
||||
longer supported. The HLS manifest parser now assumes the track to be `5.1ch` if the channels value is set to
|
||||
`.../JOC`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Support for Python `>=3.8.6,<3.9.0` has been fixed.
|
||||
- The final fallback FPS value is now only obtained from the SegmentBase's timescale value if it exists.
|
||||
- The FutureWarning that occurred when getting Segment URLs from SegmentTemplate DASH manifests has been removed.
|
||||
- The HLS manifest parser now correctly sets the audio track's `joc` parameter.
|
||||
- Some Segmented WEBVTT streams may have included the WEBVTT header data when converting to SubRip SRT. This issue has
|
||||
been fixed by separating the header from any previous caption before conversion.
|
||||
- The DASH manifest parser now uses the final redirected URL as the manifest URI (#25).
|
||||
- File move operations from or to different drives (e.g., importing a cookie from another drive in `auth add`) (#27).
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [Arias800](https://github.com/Arias800)
|
||||
- [varyg1001](https://github.com/varyg1001)
|
||||
|
||||
## [1.1.0] - 2023-02-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added utility to change the video range flag between full(pc) and limited(tv).
|
||||
- Added utility to test decoding of video and audio streams using FFmpeg.
|
||||
- Added CHANGELOG.md
|
||||
|
||||
### Changed
|
||||
|
||||
- The services and profiles listed by `auth list` are now sorted alphabetically.
|
||||
- An explicit error is now logged when adding a Cookie to a Service under a duplicate name.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected the organization name across the project from `unshackle` to `unshackle-dl` as `unshackle` was taken.
|
||||
- Fixed startup crash if the config was not yet created or was blank.
|
||||
- Fixed crash when using the `cfg` command to set a config option on new empty config files.
|
||||
- Fixed crash when loading key vaults during the `dl` command.
|
||||
- Fixed crash when using the `auth list` command when you do not have a `Cookies` data directory.
|
||||
- Fixed crash when adding a Cookie using `auth add` to a Service that has no directory yet.
|
||||
- Fixed crash when adding a Credential using `auth add` when it's the first ever credential, or first for the Service.
|
||||
|
||||
## [1.0.0] - 2023-02-06
|
||||
|
||||
Initial public release under the name unshackle.
|
||||
|
||||
[3.3.3]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.3.3
|
||||
[3.3.2]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.3.2
|
||||
[3.3.1]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.3.1
|
||||
[3.3.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.3.0
|
||||
[3.2.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.2.0
|
||||
[3.1.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.1.0
|
||||
[3.0.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v3.0.0
|
||||
[2.2.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v2.2.0
|
||||
[2.1.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v2.1.0
|
||||
[2.0.1]: https://github.com/unshackle-dl/unshackle/releases/tag/v2.0.1
|
||||
[2.0.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v2.0.0
|
||||
[1.4.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.4.0
|
||||
[1.3.1]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.3.0
|
||||
[1.2.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.2.0
|
||||
[1.1.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.1.0
|
||||
[1.0.0]: https://github.com/unshackle-dl/unshackle/releases/tag/v1.0.0
|
||||
276
README.md
Normal file
276
README.md
Normal file
@@ -0,0 +1,276 @@
|
||||
<p align="center">
|
||||
<a>unshackle</a>
|
||||
<br/>
|
||||
<sup><em>Movie, TV, and Music Archival Software</em></sup>
|
||||
</p>
|
||||
|
||||
## What is unshackle?
|
||||
|
||||
unshackle is a fork of [Devine](https://github.com/devine-dl/devine/), a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🚀 **Easy Installation** - Simple UV installation
|
||||
- 🎥 **Multi-Media Support** - Movies, TV episodes, and music
|
||||
- 🛠️ **Built-in Parsers** - DASH/HLS and ISM manifest support
|
||||
- 🔒 **DRM Support** - Widevine and PlayReady integration
|
||||
- 💾 **Flexible Storage** - Local and remote key vaults
|
||||
- 👥 **Multi-Profile Auth** - Support for cookies and credentials
|
||||
- 🤖 **Smart Naming** - Automatic P2P-style filename structure
|
||||
- ⚙️ **Configurable** - YAML-based configuration
|
||||
- ❤️ **Open Source** - Fully open-source with community contributions welcome
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
This installs the latest version directly from the GitHub repository:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/unshackle-dl/unshackle.git
|
||||
cd unshackle
|
||||
uv sync
|
||||
uv run unshackle --help
|
||||
```
|
||||
|
||||
### Install unshackle as a global (per-user) tool
|
||||
|
||||
```bash
|
||||
uv tool install git+https://github.com/unshackle-dl/unshackle.git
|
||||
# Then run:
|
||||
uvx unshackle --help # or just `unshackle` once PATH updated
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> After installation, you may need to add the installation path to your PATH environment variable if prompted.
|
||||
>
|
||||
> **Recommended:** Use `uv run unshackle` instead of direct command execution to ensure proper virtual environment activation.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```shell
|
||||
# Check available commands
|
||||
uv run unshackle --help
|
||||
|
||||
# Configure your settings
|
||||
uv run unshackle cfg --help
|
||||
|
||||
# Confirm setup and all dependencies exist
|
||||
uv run automaterr env check
|
||||
|
||||
# Download content (requires configured services)
|
||||
uv run unshackle dl SERVICE_NAME CONTENT_ID
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For comprehensive setup guides, configuration options, and advanced usage:
|
||||
|
||||
📖 **[Visit our WIKI](https://github.com/unshackle-dl/unshackle/wiki)**
|
||||
|
||||
The WIKI contains detailed information on:
|
||||
|
||||
- Installation and dependencies
|
||||
- Service configuration
|
||||
- Authentication setup
|
||||
- DRM configuration
|
||||
- Advanced features and troubleshooting
|
||||
|
||||
> [!WARNING]
|
||||
> Only create or use services for platforms you have the legal right to access.
|
||||
|
||||
For guidance on creating services, see our [WIKI documentation](https://github.com/unshackle-dl/unshackle/wiki).
|
||||
|
||||
## License
|
||||
|
||||
This software is licensed under the [GNU General Public License, Version 3.0](LICENSE).
|
||||
|
||||
**⚖️ Legal Notice**: Use unshackle responsibly and only with content you have the legal right to access and download.
|
||||
|
||||
## Services
|
||||
|
||||
unshackle doesn't include any services by default. You must create your own services for the platforms you have legal access to.
|
||||
|
||||
Unlike similar project's such as [youtube-dl], unshackle does not currently come with any Services. You must develop your
|
||||
own Services and only use unshackle with Services you have the legal right to do so.
|
||||
|
||||
> [!NOTE]
|
||||
> If you made a Service for unshackle that does not use widevine or any other DRM systems, feel free to make a Pull Request
|
||||
> and make your service available to others. Any Service on [youtube-dl] (or [yt-dlp]) would be able to be added to the
|
||||
> unshackle repository as they both use the [Unlicense license] therefore direct reading and porting of their code would be
|
||||
> legal.
|
||||
|
||||
### Service Tags
|
||||
|
||||
Service tags generally follow these rules:
|
||||
|
||||
- Tag can be between 2-4 characters long, consisting of just `[A-Z0-9i]{2,4}`.
|
||||
- Lower-case `i` is only used for select services. Specifically BBC iPlayer and iTunes.
|
||||
- If the Service's commercial name has a `+` or `Plus`, the last character should be a `P`.
|
||||
E.g., `ATVP` for `Apple TV+`, `DSCP` for `Discovery+`, `DSNP` for `Disney+`, and `PMTP` for `Paramount+`.
|
||||
|
||||
These rules are not exhaustive and should only be used as a guide. You don't strictly have to follow these rules, but we recommend doing so for consistency.
|
||||
|
||||
### Sharing Services
|
||||
|
||||
Sending and receiving zipped Service folders is quite cumbersome. Let's explore alternative routes to collaborating on
|
||||
Service Code.
|
||||
|
||||
> [!WARNING]
|
||||
> Please be careful with who you trust and what you run. The users you collaborate with on Service
|
||||
> code could update it with malicious code that you would run via unshackle on the next call.
|
||||
|
||||
#### Forking
|
||||
|
||||
If you are collaborating with a team on multiple services then forking the project is the best way to go.
|
||||
|
||||
1. Create a new Private GitHub Repository without README, .gitignore, or LICENSE files.
|
||||
Note: Do NOT use the GitHub Fork button, or you will not be able to make the repository private.
|
||||
2. `git clone <your repo url here>` and then `cd` into it.
|
||||
3. `git remote add upstream https://github.com/unshackle-dl/unshackle`
|
||||
4. `git remote set-url --push upstream DISABLE`
|
||||
5. `git fetch upstream`
|
||||
6. `git pull upstream master`
|
||||
7. (optionally) Hard reset to the latest stable version by tag. E.g., `git reset --hard v1.0.0`.
|
||||
|
||||
Now commit your Services or other changes to your forked repository.
|
||||
Once committed all your other team members can easily pull changes as well as push new changes.
|
||||
|
||||
When a new update comes out you can easily rebase your fork to that commit to update.
|
||||
|
||||
1. `git fetch upstream`
|
||||
2. `git rebase upstream/master`
|
||||
|
||||
However, please make sure you look at changes between each version before rebasing and resolve any breaking changes and
|
||||
deprecations when rebasing to a new version.
|
||||
|
||||
If you are new to `git` then take a look at [GitHub Desktop](https://desktop.github.com).
|
||||
|
||||
> [!TIP]
|
||||
> A huge benefit with this method is that you can also sync dependencies by your own Services as well!
|
||||
> Just use `poetry` to add or modify dependencies appropriately and commit the changed `poetry.lock`.
|
||||
> However, if the core project also has dependency changes your `poetry.lock` changes will conflict and you
|
||||
> will need to learn how to do conflict resolution/rebasing. It is worth it though!
|
||||
|
||||
#### Symlinking
|
||||
|
||||
This is a great option for those who wish to do something like the forking method, but may not care what changes
|
||||
happened or when and just want changes synced across a team.
|
||||
|
||||
This also opens up the ways you can host or collaborate on Service code. As long as you can receive a directory that
|
||||
updates with just the services within it, then you're good to go. Options could include an FTP server, Shared Google
|
||||
Drive, a non-fork repository with just services, and more.
|
||||
|
||||
1. Use any Cloud Source that gives you a pseudo-directory to access the Service files like a normal drive. E.g., rclone,
|
||||
Google Drive Desktop (aka File Stream), Air Drive, CloudPool, etc.
|
||||
2. Create a `services` directory somewhere in it and have all your services within it.
|
||||
3. [Symlink](https://en.wikipedia.org/wiki/Symbolic_link) the `services` directory to the `/unshackle` folder. You should
|
||||
end up with `/unshackle/services` folder containing services, not `/unshackle/services/services`.
|
||||
|
||||
You have to make sure the original folder keeps receiving and downloading/streaming those changes. You must also make
|
||||
sure that the version of unshackle you have locally is supported by the Service code.
|
||||
|
||||
> [!NOTE]
|
||||
> If you're using a cloud source that downloads the file once it gets opened, you don't have to worry as those will
|
||||
> automatically download. Python importing the files triggers the download to begin. However, it may cause a delay on
|
||||
> startup.
|
||||
|
||||
## Cookies & Credentials
|
||||
|
||||
unshackle can authenticate with Services using Cookies and/or Credentials. Credentials are stored in the config, and
|
||||
Cookies are stored in the data directory which can be found by running `unshackle env info`.
|
||||
|
||||
To add a Credential to a Service, take a look at the [Credentials Config](CONFIG.md#credentials-dictstr-strlistdict)
|
||||
for information on setting up one or more credentials per-service. You can add one or more Credential per-service and
|
||||
use `-p/--profile` to choose which Credential to use.
|
||||
|
||||
To add a Cookie to a Service, use a Cookie file extension to make a `cookies.txt` file and move it into the Cookies
|
||||
directory. You must rename the `cookies.txt` file to that of the Service tag (case-sensitive), e.g., `NF.txt`. You can
|
||||
also place it in a Service Cookie folder, e.g., `/Cookies/NF/default.txt` or `/Cookies/NF/.txt`.
|
||||
|
||||
You can add multiple Cookies to the `/Cookies/NF/` folder with their own unique name and then use `-p/--profile` to
|
||||
choose which one to use. E.g., `/Cookies/NF/sam.txt` and then use it with `--profile sam`. If you make a Service Cookie
|
||||
folder without a `.txt` or `default.txt`, but with another file, then no Cookies will be loaded unless you use
|
||||
`-p/--profile` like shown. This allows you to opt in to authentication at whim.
|
||||
|
||||
> - If your Service does not require Authentication, then do not define any Credential or Cookie for that Service.
|
||||
> - You can use both Cookies and Credentials at the same time, so long as your Service takes and uses both.
|
||||
> - If you are using profiles, then make sure you use the same name on the Credential name and Cookie file name when
|
||||
> using `-p/--profile`.
|
||||
> [!WARNING]
|
||||
> Profile names are case-sensitive and unique per-service. They have no arbitrary character or length limit, but for
|
||||
> convenience sake we don't recommend using any special characters as your terminal may get confused.
|
||||
|
||||
### Cookie file format and Extensions
|
||||
|
||||
Cookies must be in the standard Netscape cookies file format.
|
||||
Recommended Cookie exporter extensions:
|
||||
|
||||
- Firefox: "[Export Cookies]" by `Rotem Dan`
|
||||
- Chromium: "[Open Cookies.txt]" by `Ninh Pham`
|
||||
|
||||
Any other extension that exports to the standard Netscape format should theoretically work.
|
||||
|
||||
## Widevine Provisions
|
||||
|
||||
A Widevine Provision is needed for acquiring licenses containing decryption keys for DRM-protected content.
|
||||
They are not needed if you will be using unshackle on DRM-free services. Please do not ask for any widevine Device Files,
|
||||
Keys, or Provisions as they cannot be provided.
|
||||
|
||||
unshackle only supports `.WVD` files (widevine Device Files). However, if you have the Provision RSA Private Key and
|
||||
Device Client Identification Blob as blob files (e.g., `device_private_key` and `device_client_id_blob`), then you can
|
||||
convert them to a `.WVD` file by running `pywidevine create-device --help`.
|
||||
|
||||
Once you have `.WVD` files, place them in the WVDs directory which can be found by calling `uv run unshackle env info`.
|
||||
You can then set in your config which WVD (by filename only) to use by default with `uv run unshackle cfg cdm.default wvd_name`.
|
||||
From here you can then set which WVD to use for each specific service. It's best to use the lowest security-level
|
||||
provision where possible.
|
||||
|
||||
An alternative would be using a pywidevine Serve-compliant CDM API. Of course, you would need to know someone who is
|
||||
serving one, and they would need to give you access. Take a look at the [remote_cdm](CONFIG.md#remotecdm-listdict)
|
||||
config option for setup information. For further information on it see the pywidevine repository.
|
||||
|
||||
## PlayReady Device (PRD) Provisions
|
||||
|
||||
Similarly, a PlayReady Device file (.PRD) is needed for acquiring licenses and decryption keys for PlayReady DRM-protected content.
|
||||
PRD files are not required for DRM-free services. unshackle only supports `.PRD` files (PlayReady Device Files).
|
||||
|
||||
To create or manage PRD files, use the built-in CLI commands:
|
||||
|
||||
- `uv run unshackle prd new` — Create a new PRD file from device keys and certificates
|
||||
- `uv run unshackle prd reprovision` — Reprovision an existing PRD file with new keys
|
||||
- `uv run unshackle prd test` — Test a PRD file against the Microsoft PlayReady demo server
|
||||
|
||||
Once you have `.PRD` files, place them in the `PRDs/` directory (see `uv run unshackle env info` for the path).
|
||||
You can set the default PRD file in your config with `uv run unshackle cfg cdm.default prd_name`.
|
||||
Service-specific PRD files can also be set in the config, just like Widevine.
|
||||
For best compatibility, use the lowest security-level PRD file available.
|
||||
|
||||
Do not ask for PRD device files, keys, or provisions as they cannot be provided. Only use PRD files for services you have the legal right to access.
|
||||
|
||||
## End User License Agreement
|
||||
|
||||
unshackle and it's community pages should be treated with the same kindness as other projects.
|
||||
Please refrain from spam or asking for questions that infringe upon a Service's End User License Agreement.
|
||||
|
||||
1. Do not use unshackle for any purposes of which you do not have the rights to do so.
|
||||
2. Do not share or request infringing content; this includes widevine Provision Keys, Content Encryption Keys,
|
||||
or Service API Calls or Code.
|
||||
3. The Core codebase is meant to stay Free and Open-Source while the Service code should be kept private.
|
||||
4. Do not sell any part of this project, neither alone nor as part of a bundle.
|
||||
If you paid for this software or received it as part of a bundle following payment, you should demand your money
|
||||
back immediately.
|
||||
5. Be kind to one another and do not single anyone out.
|
||||
|
||||
## Licensing
|
||||
|
||||
This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE).
|
||||
You can find a copy of the license in the LICENSE file in the root folder.
|
||||
|
||||
---
|
||||
|
||||
[Export Cookies]: https://addons.mozilla.org/addon/export-cookies-txt
|
||||
[Open Cookies.txt]: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
|
||||
[Unlicense license]: https://choosealicense.com/licenses/unlicense
|
||||
[youtube-dl]: https://github.com/ytdl-org/youtube-dl
|
||||
[yt-dlp]: https://github.com/yt-dlp/yt-dlp
|
||||
71
cliff.toml
Normal file
71
cliff.toml
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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"
|
||||
47
install.bat
Normal file
47
install.bat
Normal file
@@ -0,0 +1,47 @@
|
||||
@echo off
|
||||
echo Installing unshackle dependencies...
|
||||
echo.
|
||||
|
||||
REM Check if UV is already installed
|
||||
uv --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo UV is already installed.
|
||||
goto install_deps
|
||||
)
|
||||
|
||||
echo UV not found. Installing UV...
|
||||
echo.
|
||||
|
||||
REM Install UV using the official installer
|
||||
powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
if %errorlevel% neq 0 (
|
||||
echo Failed to install UV. Please install UV manually from https://docs.astral.sh/uv/getting-started/installation/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Add UV to PATH for current session
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
|
||||
|
||||
echo UV installed successfully.
|
||||
echo.
|
||||
|
||||
:install_deps
|
||||
echo Installing project dependencies in editable mode with dev dependencies...
|
||||
echo.
|
||||
|
||||
REM Install the project in editable mode with dev dependencies
|
||||
uv sync
|
||||
if %errorlevel% neq 0 (
|
||||
echo Failed to install dependencies. Please check the error messages above.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation completed successfully!
|
||||
echo.
|
||||
echo You can now run unshackle using:
|
||||
echo uv run unshackle --help
|
||||
echo.
|
||||
pause
|
||||
114
pyproject.toml
Normal file
114
pyproject.toml
Normal file
@@ -0,0 +1,114 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unshackle"
|
||||
version = "1.0.1"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "unshackle team" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-only"
|
||||
keywords = [
|
||||
"python",
|
||||
"downloader",
|
||||
"drm",
|
||||
"widevine",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Security :: Cryptography",
|
||||
]
|
||||
dependencies = [
|
||||
"appdirs>=1.4.4,<2",
|
||||
"Brotli>=1.1.0,<2",
|
||||
"click>=8.1.8,<9",
|
||||
"construct>=2.8.8,<3",
|
||||
"crccheck>=1.3.0,<2",
|
||||
"jsonpickle>=3.0.4,<4",
|
||||
"langcodes[data]>=3.4.0,<4",
|
||||
"lxml>=5.2.1,<6",
|
||||
"pproxy>=2.7.9,<3",
|
||||
"protobuf>=4.25.3,<5",
|
||||
"pycaption>=2.2.6,<3",
|
||||
"pycryptodomex>=3.20.0,<4",
|
||||
"pyjwt>=2.8.0,<3",
|
||||
"pymediainfo>=6.1.0,<7",
|
||||
"pymp4>=1.4.0,<2",
|
||||
"pymysql>=1.1.0,<2",
|
||||
"pywidevine[serve]>=1.8.0,<2",
|
||||
"PyYAML>=6.0.1,<7",
|
||||
"requests[socks]>=2.31.0,<3",
|
||||
"rich>=13.7.1,<14",
|
||||
"rlaphoenix.m3u8>=3.4.0,<4",
|
||||
"ruamel.yaml>=0.18.6,<0.19",
|
||||
"sortedcontainers>=2.4.0,<3",
|
||||
"subtitle-filter>=1.4.9,<2",
|
||||
"Unidecode>=1.3.8,<2",
|
||||
"urllib3>=2.2.1,<3",
|
||||
"chardet>=5.2.0,<6",
|
||||
"curl-cffi>=0.7.0b4,<0.8",
|
||||
"pyplayready>=0.6.0,<0.7",
|
||||
"httpx>=0.28.1,<0.29",
|
||||
"cryptography>=45.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/unshackle-dl/unshackle"
|
||||
Repository = "https://github.com/unshackle-dl/unshackle"
|
||||
Issues = "https://github.com/unshackle-dl/unshackle/issues"
|
||||
Discussions = "https://github.com/unshackle-dl/unshackle/discussions"
|
||||
Changelog = "https://github.com/unshackle-dl/unshackle/blob/master/CHANGELOG.md"
|
||||
|
||||
[project.scripts]
|
||||
unshackle = "unshackle.core.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=3.7.0,<4",
|
||||
"mypy>=1.9.0,<2",
|
||||
"mypy-protobuf>=3.6.0,<4",
|
||||
"types-protobuf>=4.24.0.20240408,<5",
|
||||
"types-PyMySQL>=1.1.0.1,<2",
|
||||
"types-requests>=2.31.0.20240406,<3",
|
||||
"isort>=5.13.2,<6",
|
||||
"ruff~=0.3.7",
|
||||
"unshackle",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["unshackle"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"CHANGELOG.md",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
force-exclude = true
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "W"]
|
||||
|
||||
[tool.isort]
|
||||
line_length = 118
|
||||
|
||||
[tool.mypy]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_defs = true
|
||||
follow_imports = "silent"
|
||||
ignore_missing_imports = true
|
||||
no_implicit_optional = true
|
||||
|
||||
[tool.uv.sources]
|
||||
unshackle = { workspace = true }
|
||||
4
unshackle/__main__.py
Normal file
4
unshackle/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
if __name__ == "__main__":
|
||||
from unshackle.core.__main__ import main
|
||||
|
||||
main()
|
||||
0
unshackle/commands/__init__.py
Normal file
0
unshackle/commands/__init__.py
Normal file
90
unshackle/commands/cfg.py
Normal file
90
unshackle/commands/cfg.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import ast
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from unshackle.core.config import config, get_config_path
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.command(
|
||||
short_help="Manage configuration values for the program and its services.", context_settings=context_settings
|
||||
)
|
||||
@click.argument("key", type=str, required=False)
|
||||
@click.argument("value", type=str, required=False)
|
||||
@click.option("--unset", is_flag=True, default=False, help="Unset/remove the configuration value.")
|
||||
@click.option("--list", "list_", is_flag=True, default=False, help="List all set configuration values.")
|
||||
@click.pass_context
|
||||
def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> None:
|
||||
"""
|
||||
Manage configuration values for the program and its services.
|
||||
|
||||
\b
|
||||
Known Issues:
|
||||
- Config changes remove all comments of the changed files, which may hold critical data. (#14)
|
||||
"""
|
||||
if not key and not value and not list_:
|
||||
raise click.UsageError("Nothing to do.", ctx)
|
||||
|
||||
if value:
|
||||
try:
|
||||
value = ast.literal_eval(value)
|
||||
except (ValueError, SyntaxError):
|
||||
pass # probably a str without quotes or similar, assume it's a string value
|
||||
|
||||
log = logging.getLogger("cfg")
|
||||
|
||||
yaml, data = YAML(), None
|
||||
yaml.default_flow_style = False
|
||||
|
||||
config_path = get_config_path() or config.directories.user_configs / config.filenames.root_config
|
||||
if config_path.exists():
|
||||
data = yaml.load(config_path)
|
||||
|
||||
if not data:
|
||||
log.warning("No config file was found or it has no data, yet")
|
||||
# yaml.load() returns `None` if the input data is blank instead of a usable object
|
||||
# force a usable object by making one and removing the only item within it
|
||||
data = yaml.load("""__TEMP__: null""")
|
||||
del data["__TEMP__"]
|
||||
|
||||
if list_:
|
||||
yaml.dump(data, sys.stdout)
|
||||
return
|
||||
|
||||
key_items = key.split(".")
|
||||
parent_key = key_items[:-1]
|
||||
trailing_key = key_items[-1]
|
||||
|
||||
is_write = value is not None
|
||||
is_delete = unset
|
||||
if is_write and is_delete:
|
||||
raise click.ClickException("You cannot set a value and use --unset at the same time.")
|
||||
|
||||
if not is_write and not is_delete:
|
||||
data = data.mlget(key_items, default=KeyError)
|
||||
if data == KeyError:
|
||||
raise click.ClickException(f"Key '{key}' does not exist in the config.")
|
||||
yaml.dump(data, sys.stdout)
|
||||
else:
|
||||
try:
|
||||
parent_data = data
|
||||
if parent_key:
|
||||
parent_data = data.mlget(parent_key, default=data)
|
||||
if parent_data == data:
|
||||
for key in parent_key:
|
||||
if not hasattr(parent_data, key):
|
||||
parent_data[key] = {}
|
||||
parent_data = parent_data[key]
|
||||
if is_write:
|
||||
parent_data[trailing_key] = value
|
||||
log.info(f"Set {key} to {repr(value)}")
|
||||
elif is_delete:
|
||||
del parent_data[trailing_key]
|
||||
log.info(f"Unset {key}")
|
||||
except KeyError:
|
||||
raise click.ClickException(f"Key '{key}' does not exist in the config.")
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
yaml.dump(data, config_path)
|
||||
1250
unshackle/commands/dl.py
Normal file
1250
unshackle/commands/dl.py
Normal file
File diff suppressed because it is too large
Load Diff
139
unshackle/commands/env.py
Normal file
139
unshackle/commands/env.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.padding import Padding
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from unshackle.core.config import POSSIBLE_CONFIG_PATHS, config, config_path
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.osenvironment import get_os_arch
|
||||
|
||||
|
||||
@click.group(short_help="Manage and configure the project environment.", context_settings=context_settings)
|
||||
def env() -> None:
|
||||
"""Manage and configure the project environment."""
|
||||
|
||||
|
||||
@env.command()
|
||||
def check() -> None:
|
||||
"""Checks environment for the required dependencies."""
|
||||
table = Table(title="Dependencies", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Installed", justify="center")
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
# builds shaka-packager based on os, arch
|
||||
packager_dep = get_os_arch("packager")
|
||||
|
||||
# Helper function to find binary with multiple possible names
|
||||
def find_binary(*names):
|
||||
for name in names:
|
||||
if shutil.which(name):
|
||||
return name
|
||||
return names[0] # Return first name as fallback for display
|
||||
|
||||
dependencies = [
|
||||
{"name": "CCExtractor", "binary": "ccextractor"},
|
||||
{"name": "FFMpeg", "binary": "ffmpeg"},
|
||||
{"name": "MKVToolNix", "binary": "mkvmerge"},
|
||||
{"name": "Shaka-Packager", "binary": packager_dep},
|
||||
{"name": "N_m3u8DL-RE", "binary": find_binary("N_m3u8DL-RE", "n-m3u8dl-re")},
|
||||
{"name": "Aria2(c)", "binary": "aria2c"},
|
||||
]
|
||||
|
||||
for dep in dependencies:
|
||||
path = shutil.which(dep["binary"])
|
||||
|
||||
if path:
|
||||
installed = "[green]:heavy_check_mark:[/green]"
|
||||
path_output = path.lower()
|
||||
else:
|
||||
installed = "[red]:x:[/red]"
|
||||
path_output = "Not Found"
|
||||
|
||||
# Add to the table
|
||||
table.add_row(dep["name"], installed, path_output)
|
||||
|
||||
# Display the result
|
||||
console.print(Padding(table, (1, 5)))
|
||||
|
||||
|
||||
@env.command()
|
||||
def info() -> None:
|
||||
"""Displays information about the current environment."""
|
||||
log = logging.getLogger("env")
|
||||
|
||||
if config_path:
|
||||
log.info(f"Config loaded from {config_path}")
|
||||
else:
|
||||
tree = Tree("No config file found, you can use any of the following locations:")
|
||||
for i, path in enumerate(POSSIBLE_CONFIG_PATHS, start=1):
|
||||
tree.add(f"[repr.number]{i}.[/] [text2]{path.resolve()}[/]")
|
||||
console.print(Padding(tree, (0, 5)))
|
||||
|
||||
table = Table(title="Directories", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
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)
|
||||
def clear() -> None:
|
||||
"""Clear an environment directory."""
|
||||
|
||||
|
||||
@clear.command()
|
||||
@click.argument("service", type=str, required=False)
|
||||
def cache(service: Optional[str]) -> None:
|
||||
"""Clear the environment cache directory."""
|
||||
log = logging.getLogger("env")
|
||||
cache_dir = config.directories.cache
|
||||
if service:
|
||||
cache_dir = cache_dir / Services.get_tag(service)
|
||||
log.info(f"Clearing cache directory: {cache_dir}")
|
||||
files_count = len(list(cache_dir.glob("**/*")))
|
||||
if not files_count:
|
||||
log.info("No files to delete")
|
||||
else:
|
||||
log.info(f"Deleting {files_count} files...")
|
||||
shutil.rmtree(cache_dir)
|
||||
log.info("Cleared")
|
||||
|
||||
|
||||
@clear.command()
|
||||
def temp() -> None:
|
||||
"""Clear the environment temp directory."""
|
||||
log = logging.getLogger("env")
|
||||
log.info(f"Clearing temp directory: {config.directories.temp}")
|
||||
files_count = len(list(config.directories.temp.glob("**/*")))
|
||||
if not files_count:
|
||||
log.info("No files to delete")
|
||||
else:
|
||||
log.info(f"Deleting {files_count} files...")
|
||||
shutil.rmtree(config.directories.temp)
|
||||
log.info("Cleared")
|
||||
200
unshackle/commands/kv.py
Normal file
200
unshackle/commands/kv.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.vault import Vault
|
||||
from unshackle.core.vaults import Vaults
|
||||
|
||||
|
||||
@click.group(short_help="Manage and configure Key Vaults.", context_settings=context_settings)
|
||||
def kv() -> None:
|
||||
"""Manage and configure Key Vaults."""
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("to_vault", type=str)
|
||||
@click.argument("from_vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.option("-s", "--service", type=str, default=None, help="Only copy data to and from a specific service.")
|
||||
def copy(to_vault: str, from_vaults: list[str], service: Optional[str] = None) -> None:
|
||||
"""
|
||||
Copy data from multiple Key Vaults into a single Key Vault.
|
||||
Rows with matching KIDs are skipped unless there's no KEY set.
|
||||
Existing data is not deleted or altered.
|
||||
|
||||
The `to_vault` argument is the key vault you wish to copy data to.
|
||||
It should be the name of a Key Vault defined in the config.
|
||||
|
||||
The `from_vaults` argument is the key vault(s) you wish to take
|
||||
data from. You may supply multiple key vaults.
|
||||
"""
|
||||
if not from_vaults:
|
||||
raise click.ClickException("No Vaults were specified to copy data from.")
|
||||
|
||||
log = logging.getLogger("kv")
|
||||
|
||||
vaults = Vaults()
|
||||
for vault_name in [to_vault] + list(from_vaults):
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults.load(vault_type, **vault_args)
|
||||
|
||||
to_vault: Vault = vaults.vaults[0]
|
||||
from_vaults: list[Vault] = vaults.vaults[1:]
|
||||
|
||||
log.info(f"Copying data from {', '.join([x.name for x in from_vaults])}, into {to_vault.name}")
|
||||
if service:
|
||||
service = Services.get_tag(service)
|
||||
log.info(f"Only copying data for service {service}")
|
||||
|
||||
total_added = 0
|
||||
for from_vault in from_vaults:
|
||||
if service:
|
||||
services = [service]
|
||||
else:
|
||||
services = from_vault.get_services()
|
||||
|
||||
for service_ in services:
|
||||
log.info(f"Getting data from {from_vault} for {service_}")
|
||||
content_keys = list(from_vault.get_keys(service_)) # important as it's a generator we iterate twice
|
||||
|
||||
bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)}
|
||||
|
||||
for kid, key in bad_keys.items():
|
||||
log.warning(f"Cannot add a NULL Content Key to a Vault, skipping: {kid}:{key}")
|
||||
|
||||
content_keys = {kid: key for kid, key in content_keys if kid not in bad_keys}
|
||||
|
||||
total_count = len(content_keys)
|
||||
log.info(f"Adding {total_count} Content Keys to {to_vault} for {service_}")
|
||||
|
||||
try:
|
||||
added = to_vault.add_keys(service_, content_keys)
|
||||
except PermissionError:
|
||||
log.warning(f" - No permission to create table ({service_}) in {to_vault}, skipping...")
|
||||
continue
|
||||
|
||||
total_added += added
|
||||
existed = total_count - added
|
||||
|
||||
log.info(f"{to_vault} ({service_}): {added} newly added, {existed} already existed (skipped)")
|
||||
|
||||
log.info(f"{to_vault}: {total_added} total newly added")
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.option("-s", "--service", type=str, default=None, help="Only sync data to and from a specific service.")
|
||||
@click.pass_context
|
||||
def sync(ctx: click.Context, vaults: list[str], service: Optional[str] = None) -> None:
|
||||
"""
|
||||
Ensure multiple Key Vaults copies of all keys as each other.
|
||||
It's essentially just a bi-way copy between each vault.
|
||||
To see the precise details of what it's doing between each
|
||||
provided vault, see the documentation for the `copy` command.
|
||||
"""
|
||||
if not len(vaults) > 1:
|
||||
raise click.ClickException("You must provide more than one Vault to sync.")
|
||||
|
||||
ctx.invoke(copy, to_vault=vaults[0], from_vaults=vaults[1:], service=service)
|
||||
for i in range(1, len(vaults)):
|
||||
ctx.invoke(copy, to_vault=vaults[i], from_vaults=[vaults[i - 1]], service=service)
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("file", type=Path)
|
||||
@click.argument("service", type=str)
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
def add(file: Path, service: str, vaults: list[str]) -> None:
|
||||
"""
|
||||
Add new Content Keys to Key Vault(s) by service.
|
||||
|
||||
File should contain one key per line in the format KID:KEY (HEX:HEX).
|
||||
Each line should have nothing else within it except for the KID:KEY.
|
||||
Encoding is presumed to be UTF8.
|
||||
"""
|
||||
if not file.exists():
|
||||
raise click.ClickException(f"File provided ({file}) does not exist.")
|
||||
if not file.is_file():
|
||||
raise click.ClickException(f"File provided ({file}) is not a file.")
|
||||
if not service or not isinstance(service, str):
|
||||
raise click.ClickException(f"Service provided ({service}) is invalid.")
|
||||
if len(vaults) < 1:
|
||||
raise click.ClickException("You must provide at least one Vault.")
|
||||
|
||||
log = logging.getLogger("kv")
|
||||
service = Services.get_tag(service)
|
||||
|
||||
vaults_ = Vaults()
|
||||
for vault_name in vaults:
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults_.load(vault_type, **vault_args)
|
||||
|
||||
data = file.read_text(encoding="utf8")
|
||||
kid_keys: dict[str, str] = {}
|
||||
for line in data.splitlines(keepends=False):
|
||||
line = line.strip()
|
||||
match = re.search(r"^(?P<kid>[0-9a-fA-F]{32}):(?P<key>[0-9a-fA-F]{32})$", line)
|
||||
if not match:
|
||||
continue
|
||||
kid = match.group("kid").lower()
|
||||
key = match.group("key").lower()
|
||||
kid_keys[kid] = key
|
||||
|
||||
total_count = len(kid_keys)
|
||||
|
||||
for vault in vaults_:
|
||||
log.info(f"Adding {total_count} Content Keys to {vault}")
|
||||
added_count = vault.add_keys(service, kid_keys)
|
||||
existed_count = total_count - added_count
|
||||
log.info(f"{vault}: {added_count} newly added, {existed_count} already existed (skipped)")
|
||||
|
||||
log.info("Done!")
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
def prepare(vaults: list[str]) -> None:
|
||||
"""Create Service Tables on Vaults if not yet created."""
|
||||
log = logging.getLogger("kv")
|
||||
|
||||
vaults_ = Vaults()
|
||||
for vault_name in vaults:
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults_.load(vault_type, **vault_args)
|
||||
|
||||
for vault in vaults_:
|
||||
if hasattr(vault, "has_table") and hasattr(vault, "create_table"):
|
||||
for service_tag in Services.get_tags():
|
||||
if vault.has_table(service_tag):
|
||||
log.info(f"{vault} already has a {service_tag} Table")
|
||||
else:
|
||||
try:
|
||||
vault.create_table(service_tag, commit=True)
|
||||
log.info(f"{vault}: Created {service_tag} Table")
|
||||
except PermissionError:
|
||||
log.error(f"{vault} user has no create table permission, skipping...")
|
||||
continue
|
||||
else:
|
||||
log.info(f"{vault} does not use tables, skipping...")
|
||||
|
||||
log.info("Done!")
|
||||
271
unshackle/commands/prd.py
Normal file
271
unshackle/commands/prd.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import requests
|
||||
from Crypto.Random import get_random_bytes
|
||||
from pyplayready.cdm import Cdm
|
||||
from pyplayready.crypto.ecc_key import ECCKey
|
||||
from pyplayready.device import Device
|
||||
from pyplayready.exceptions import InvalidCertificateChain, OutdatedDevice
|
||||
from pyplayready.system.bcert import Certificate, CertificateChain
|
||||
from pyplayready.system.pssh import PSSH
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(
|
||||
short_help="Manage creation of PRD (Playready Device) files.",
|
||||
context_settings=context_settings,
|
||||
)
|
||||
def prd() -> None:
|
||||
"""Manage creation of PRD (Playready Device) files."""
|
||||
|
||||
|
||||
@prd.command()
|
||||
@click.argument("paths", type=Path, nargs=-1)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--encryption_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private encryption key",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--signing_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private signing key",
|
||||
)
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def new(
|
||||
ctx: click.Context,
|
||||
paths: tuple[Path, ...],
|
||||
encryption_key: Optional[Path],
|
||||
signing_key: Optional[Path],
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""Create a new .PRD PlayReady Device file.
|
||||
|
||||
Accepts either paths to a group key and certificate or a single directory
|
||||
containing ``zgpriv.dat`` and ``bgroupcert.dat``.
|
||||
"""
|
||||
if len(paths) == 1 and paths[0].is_dir():
|
||||
device_dir = paths[0]
|
||||
group_key = device_dir / "zgpriv.dat"
|
||||
group_certificate = device_dir / "bgroupcert.dat"
|
||||
if not group_key.is_file() or not group_certificate.is_file():
|
||||
raise click.UsageError("Folder must contain zgpriv.dat and bgroupcert.dat", ctx)
|
||||
elif len(paths) == 2:
|
||||
group_key, group_certificate = paths
|
||||
if not group_key.is_file():
|
||||
raise click.UsageError("group_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not group_certificate.is_file():
|
||||
raise click.UsageError("group_certificate: Not a path to a file, or it doesn't exist.", ctx)
|
||||
device_dir = None
|
||||
else:
|
||||
raise click.UsageError(
|
||||
"Provide either a folder path or paths to group_key and group_certificate",
|
||||
ctx,
|
||||
)
|
||||
if encryption_key and not encryption_key.is_file():
|
||||
raise click.UsageError("encryption_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if signing_key and not signing_key.is_file():
|
||||
raise click.UsageError("signing_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
|
||||
encryption_key_obj = ECCKey.load(encryption_key) if encryption_key else ECCKey.generate()
|
||||
signing_key_obj = ECCKey.load(signing_key) if signing_key else ECCKey.generate()
|
||||
|
||||
group_key_obj = ECCKey.load(group_key)
|
||||
certificate_chain = CertificateChain.load(group_certificate)
|
||||
|
||||
if certificate_chain.get(0).get_issuer_key() != group_key_obj.public_bytes():
|
||||
raise InvalidCertificateChain("Group key does not match this certificate")
|
||||
|
||||
new_certificate = Certificate.new_leaf_cert(
|
||||
cert_id=get_random_bytes(16),
|
||||
security_level=certificate_chain.get_security_level(),
|
||||
client_id=get_random_bytes(16),
|
||||
signing_key=signing_key_obj,
|
||||
encryption_key=encryption_key_obj,
|
||||
group_key=group_key_obj,
|
||||
parent=certificate_chain,
|
||||
)
|
||||
certificate_chain.prepend(new_certificate)
|
||||
certificate_chain.verify()
|
||||
|
||||
device = Device(
|
||||
group_key=group_key_obj.dumps(),
|
||||
encryption_key=encryption_key_obj.dumps(),
|
||||
signing_key=signing_key_obj.dumps(),
|
||||
group_certificate=certificate_chain.dumps(),
|
||||
)
|
||||
|
||||
if output and output.suffix:
|
||||
if output.suffix.lower() != ".prd":
|
||||
log.warning(
|
||||
"Saving PRD with the file extension '%s' but '.prd' is recommended.",
|
||||
output.suffix,
|
||||
)
|
||||
out_path = output
|
||||
else:
|
||||
out_dir = output or (device_dir or config.directories.prds)
|
||||
out_path = out_dir / f"{device.get_name()}.prd"
|
||||
|
||||
if out_path.exists():
|
||||
log.error("A file already exists at the path '%s', cannot overwrite.", out_path)
|
||||
return
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(device.dumps())
|
||||
|
||||
log.info("Created Playready Device (.prd) file, %s", out_path.name)
|
||||
log.info(" + Security Level: %s", device.security_level)
|
||||
log.info(" + Group Key: %s bytes", len(device.group_key.dumps()))
|
||||
log.info(" + Encryption Key: %s bytes", len(device.encryption_key.dumps()))
|
||||
log.info(" + Signing Key: %s bytes", len(device.signing_key.dumps()))
|
||||
log.info(" + Group Certificate: %s bytes", len(device.group_certificate.dumps()))
|
||||
log.info(" + Saved to: %s", out_path.absolute())
|
||||
|
||||
|
||||
@prd.command(name="reprovision")
|
||||
@click.argument("prd_path", type=Path)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--encryption_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private encryption key",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--signing_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private signing key",
|
||||
)
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||
@click.pass_context
|
||||
def reprovision_device(
|
||||
ctx: click.Context,
|
||||
prd_path: Path,
|
||||
encryption_key: Optional[Path],
|
||||
signing_key: Optional[Path],
|
||||
output: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Reprovision a Playready Device (.prd) file."""
|
||||
if not prd_path.is_file():
|
||||
raise click.UsageError("prd_path: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
log.info("Reprovisioning Playready Device (.prd) file, %s", prd_path.name)
|
||||
|
||||
device = Device.load(prd_path)
|
||||
|
||||
if device.group_key is None:
|
||||
raise OutdatedDevice(
|
||||
"Device does not support reprovisioning, re-create it or use a Device with a version of 3 or higher"
|
||||
)
|
||||
|
||||
device.group_certificate.remove(0)
|
||||
|
||||
encryption_key_obj = ECCKey.load(encryption_key) if encryption_key else ECCKey.generate()
|
||||
signing_key_obj = ECCKey.load(signing_key) if signing_key else ECCKey.generate()
|
||||
|
||||
device.encryption_key = encryption_key_obj
|
||||
device.signing_key = signing_key_obj
|
||||
|
||||
new_certificate = Certificate.new_leaf_cert(
|
||||
cert_id=get_random_bytes(16),
|
||||
security_level=device.group_certificate.get_security_level(),
|
||||
client_id=get_random_bytes(16),
|
||||
signing_key=signing_key_obj,
|
||||
encryption_key=encryption_key_obj,
|
||||
group_key=device.group_key,
|
||||
parent=device.group_certificate,
|
||||
)
|
||||
device.group_certificate.prepend(new_certificate)
|
||||
|
||||
if output and output.suffix:
|
||||
if output.suffix.lower() != ".prd":
|
||||
log.warning(
|
||||
"Saving PRD with the file extension '%s' but '.prd' is recommended.",
|
||||
output.suffix,
|
||||
)
|
||||
out_path = output
|
||||
else:
|
||||
out_path = prd_path
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(device.dumps())
|
||||
|
||||
log.info("Reprovisioned Playready Device (.prd) file, %s", out_path.name)
|
||||
|
||||
|
||||
@prd.command()
|
||||
@click.argument("device", type=Path)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--ckt",
|
||||
type=click.Choice(["aesctr", "aescbc"], case_sensitive=False),
|
||||
default="aesctr",
|
||||
help="Content Key Encryption Type",
|
||||
)
|
||||
@click.option(
|
||||
"-sl",
|
||||
"--security-level",
|
||||
type=click.Choice(["150", "2000", "3000"], case_sensitive=False),
|
||||
default="2000",
|
||||
help="Minimum Security Level",
|
||||
)
|
||||
@click.pass_context
|
||||
def test(
|
||||
ctx: click.Context,
|
||||
device: Path,
|
||||
ckt: str,
|
||||
security_level: str,
|
||||
) -> None:
|
||||
"""Test a Playready Device on the Microsoft demo server."""
|
||||
|
||||
if not device.is_file():
|
||||
raise click.UsageError("device: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
|
||||
prd_device = Device.load(device)
|
||||
log.info("Loaded Device: %s", prd_device.get_name())
|
||||
|
||||
cdm = Cdm.from_device(prd_device)
|
||||
log.info("Loaded CDM")
|
||||
|
||||
session_id = cdm.open()
|
||||
log.info("Opened Session")
|
||||
|
||||
pssh_b64 = "AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABhAHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQAUgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgA0AFIAcABsAGIAKwBUAGIATgBFAFMAOAB0AEcAawBOAEYAVwBUAEUASABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEsATABqADMAUQB6AFEAUAAvAE4AQQA9ADwALwBDAEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAHAAcgBvAGYAZgBpAGMAaQBhAGwAcwBpAHQAZQAuAGsAZQB5AGQAZQBsAGkAdgBlAHIAeQAuAG0AZQBkAGkAYQBzAGUAcgB2AGkAYwBlAHMALgB3AGkAbgBkAG8AdwBzAC4AbgBlAHQALwBQAGwAYQB5AFIAZQBhAGQAeQAvADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwASQBJAFMAXwBEAFIATQBfAFYARQBSAFMASQBPAE4APgA4AC4AMQAuADIAMwAwADQALgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="
|
||||
pssh = PSSH(pssh_b64)
|
||||
|
||||
challenge = cdm.get_license_challenge(session_id, pssh.wrm_headers[0])
|
||||
log.info("Created License Request")
|
||||
|
||||
license_server = f"https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:{security_level},ckt:{ckt})"
|
||||
|
||||
response = requests.post(
|
||||
url=license_server,
|
||||
headers={"Content-Type": "text/xml; charset=UTF-8"},
|
||||
data=challenge,
|
||||
)
|
||||
|
||||
cdm.parse_license(session_id, response.text)
|
||||
log.info("License Parsed Successfully")
|
||||
|
||||
for key in cdm.get_keys(session_id):
|
||||
log.info(f"{key.key_id.hex}:{key.key.hex()}")
|
||||
|
||||
cdm.close(session_id)
|
||||
log.info("Closed Session")
|
||||
149
unshackle/commands/search.py
Normal file
149
unshackle/commands/search.py
Normal file
@@ -0,0 +1,149 @@
|
||||
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 unshackle.commands.dl import dl
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.proxies import Basic, Hola, NordVPN
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.click_types import ContextData
|
||||
from unshackle.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))
|
||||
)
|
||||
45
unshackle/commands/serve.py
Normal file
45
unshackle/commands/serve.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.command(short_help="Serve your Local Widevine Devices for Remote Access.", context_settings=context_settings)
|
||||
@click.option("-h", "--host", type=str, default="0.0.0.0", help="Host to serve from.")
|
||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||
@click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.")
|
||||
def serve(host: str, port: int, caddy: bool) -> None:
|
||||
"""
|
||||
Serve your Local Widevine Devices for Remote Access.
|
||||
|
||||
\b
|
||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||
|
||||
\b
|
||||
You may serve with Caddy at the same time with --caddy. You can use Caddy
|
||||
as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile
|
||||
next to the unshackle config.
|
||||
"""
|
||||
from pywidevine import serve
|
||||
|
||||
if caddy:
|
||||
if not binaries.Caddy:
|
||||
raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.')
|
||||
caddy_p = subprocess.Popen(
|
||||
[binaries.Caddy, "run", "--config", str(config.directories.user_configs / "Caddyfile")]
|
||||
)
|
||||
else:
|
||||
caddy_p = None
|
||||
|
||||
try:
|
||||
if not config.serve.get("devices"):
|
||||
config.serve["devices"] = []
|
||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
||||
serve.run(config.serve, host, port)
|
||||
finally:
|
||||
if caddy_p:
|
||||
caddy_p.kill()
|
||||
267
unshackle/commands/util.py
Normal file
267
unshackle/commands/util.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pymediainfo import MediaInfo
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(short_help="Various helper scripts and programs.", context_settings=context_settings)
|
||||
def util() -> None:
|
||||
"""Various helper scripts and programs."""
|
||||
|
||||
|
||||
@util.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.argument("aspect", type=str)
|
||||
@click.option(
|
||||
"--letter/--pillar",
|
||||
default=True,
|
||||
help="Specify which direction to crop. Top and Bottom would be --letter, Sides would be --pillar.",
|
||||
)
|
||||
@click.option("-o", "--offset", type=int, default=0, help="Fine tune the computed crop area if not perfectly centered.")
|
||||
@click.option(
|
||||
"-p",
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Instantly preview the newly-set aspect crop in MPV (or ffplay if mpv is unavailable).",
|
||||
)
|
||||
def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> None:
|
||||
"""
|
||||
Losslessly crop H.264 and H.265 video files at the bit-stream level.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
Note: If you notice that the values you put in are not quite working, try
|
||||
tune -o/--offset. This may be necessary on videos with sub-sampled chroma.
|
||||
|
||||
Do note that you may not get an ideal lossless cropping result on some
|
||||
cases, again due to sub-sampled chroma.
|
||||
|
||||
It's recommended that you try -o about 10 or so pixels and lower it until
|
||||
you get as close in as possible. Do make sure it's not over-cropping either
|
||||
as it may go from being 2px away from a perfect crop, to 20px over-cropping
|
||||
again due to sub-sampled chroma.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
try:
|
||||
video_track = next(iter(MediaInfo.parse(video_path).video_tracks or []))
|
||||
except StopIteration:
|
||||
raise click.ClickException("There's no video tracks in the provided file.")
|
||||
|
||||
crop_filter = {"HEVC": "hevc_metadata", "AVC": "h264_metadata"}.get(video_track.commercial_name)
|
||||
if not crop_filter:
|
||||
raise click.ClickException(f"{video_track.commercial_name} Codec not supported.")
|
||||
|
||||
aspect_w, aspect_h = list(map(float, aspect.split(":")))
|
||||
if letter:
|
||||
crop_value = (video_track.height - (video_track.width / (aspect_w * aspect_h))) / 2
|
||||
left, top, right, bottom = map(int, [0, crop_value + offset, 0, crop_value - offset])
|
||||
else:
|
||||
crop_value = (video_track.width - (video_track.height * (aspect_w / aspect_h))) / 2
|
||||
left, top, right, bottom = map(int, [crop_value + offset, 0, crop_value - offset, 0])
|
||||
crop_filter += f"=crop_left={left}:crop_top={top}:crop_right={right}:crop_bottom={bottom}"
|
||||
|
||||
if min(left, top, right, bottom) < 0:
|
||||
raise click.ClickException("Cannot crop less than 0, are you cropping in the right direction?")
|
||||
|
||||
if preview:
|
||||
out_path = ["-f", "mpegts", "-"] # pipe
|
||||
else:
|
||||
out_path = [
|
||||
str(
|
||||
video_path.with_name(
|
||||
".".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
video_path.stem,
|
||||
video_track.language,
|
||||
"crop",
|
||||
str(offset or ""),
|
||||
{
|
||||
# ffmpeg's MKV muxer does not yet support HDR
|
||||
"HEVC": "h265",
|
||||
"AVC": "h264",
|
||||
}.get(video_track.commercial_name, ".mp4"),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
ffmpeg_call = subprocess.Popen(
|
||||
[binaries.FFMPEG, "-y", "-i", str(video_path), "-map", "0:v:0", "-c", "copy", "-bsf:v", crop_filter]
|
||||
+ out_path,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
if preview:
|
||||
previewer = binaries.MPV or binaries.FFPlay
|
||||
if not previewer:
|
||||
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
|
||||
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
|
||||
finally:
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command(name="range")
|
||||
@click.argument("path", type=Path)
|
||||
@click.option("--full/--limited", is_flag=True, help="Full: 0..255, Limited: 16..235 (16..240 YUV luma)")
|
||||
@click.option(
|
||||
"-p",
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Instantly preview the newly-set video range in MPV (or ffplay if mpv is unavailable).",
|
||||
)
|
||||
def range_(path: Path, full: bool, preview: bool) -> None:
|
||||
"""
|
||||
Losslessly set the Video Range flag to full or limited at the bit-stream level.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
If you ever notice blacks not being quite black, and whites not being quite white,
|
||||
then you're video may have the range set to the wrong value. Flip its range to the
|
||||
opposite value and see if that fixes it.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
try:
|
||||
video_track = next(iter(MediaInfo.parse(video_path).video_tracks or []))
|
||||
except StopIteration:
|
||||
raise click.ClickException("There's no video tracks in the provided file.")
|
||||
|
||||
metadata_key = {"HEVC": "hevc_metadata", "AVC": "h264_metadata"}.get(video_track.commercial_name)
|
||||
if not metadata_key:
|
||||
raise click.ClickException(f"{video_track.commercial_name} Codec not supported.")
|
||||
|
||||
if preview:
|
||||
out_path = ["-f", "mpegts", "-"] # pipe
|
||||
else:
|
||||
out_path = [
|
||||
str(
|
||||
video_path.with_name(
|
||||
".".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
video_path.stem,
|
||||
video_track.language,
|
||||
"range",
|
||||
["limited", "full"][full],
|
||||
{
|
||||
# ffmpeg's MKV muxer does not yet support HDR
|
||||
"HEVC": "h265",
|
||||
"AVC": "h264",
|
||||
}.get(video_track.commercial_name, ".mp4"),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
ffmpeg_call = subprocess.Popen(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-bsf:v",
|
||||
f"{metadata_key}=video_full_range_flag={int(full)}",
|
||||
]
|
||||
+ out_path,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
if preview:
|
||||
previewer = binaries.MPV or binaries.FFPlay
|
||||
if not previewer:
|
||||
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
|
||||
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
|
||||
finally:
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.option(
|
||||
"-m", "--map", "map_", type=str, default="0", help="Test specific streams by setting FFmpeg's -map parameter."
|
||||
)
|
||||
def test(path: Path, map_: str) -> None:
|
||||
"""
|
||||
Decode an entire video and check for any corruptions or errors using FFmpeg.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
Tests all streams within the file by default. Subtitles cannot be tested.
|
||||
You may choose specific streams using the -m/--map parameter. E.g.,
|
||||
'0:v:0' to test the first video stream, or '0:a' to test all audio streams.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
print("Starting...")
|
||||
p = subprocess.Popen(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-benchmark",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-map",
|
||||
map_,
|
||||
"-sn",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
reached_output = False
|
||||
errors = 0
|
||||
for line in p.stderr:
|
||||
line = line.strip()
|
||||
if "speed=" in line:
|
||||
reached_output = True
|
||||
if not reached_output:
|
||||
continue
|
||||
if line.startswith("["): # error of some kind
|
||||
errors += 1
|
||||
stream, error = line.split("] ", maxsplit=1)
|
||||
stream = stream.split(" @ ")[0]
|
||||
line = f"{stream} ERROR: {error}"
|
||||
print(line)
|
||||
p.stderr.close()
|
||||
print(f"Finished with {errors} Errors, Cleaning up...")
|
||||
p.terminate()
|
||||
p.wait()
|
||||
272
unshackle/commands/wvd.py
Normal file
272
unshackle/commands/wvd.py
Normal file
@@ -0,0 +1,272 @@
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from pywidevine.device import Device, DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import FileHashes
|
||||
from rich.prompt import Prompt
|
||||
from unidecode import UnidecodeError, unidecode
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(
|
||||
short_help="Manage configuration and creation of WVD (Widevine Device) files.", context_settings=context_settings
|
||||
)
|
||||
def wvd() -> None:
|
||||
"""Manage configuration and creation of WVD (Widevine Device) files."""
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("paths", type=Path, nargs=-1)
|
||||
def add(paths: list[Path]) -> None:
|
||||
"""Add one or more WVD (Widevine Device) files to the WVDs Directory."""
|
||||
log = logging.getLogger("wvd")
|
||||
for path in paths:
|
||||
dst_path = config.directories.wvds / path.name
|
||||
|
||||
if not path.exists():
|
||||
log.error(f"The WVD path '{path}' does not exist...")
|
||||
elif dst_path.exists():
|
||||
log.error(f"WVD named '{path.stem}' already exists...")
|
||||
else:
|
||||
# TODO: Check for and log errors
|
||||
_ = Device.load(path) # test if WVD is valid
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(path, dst_path)
|
||||
log.info(f"Added {path.stem}")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("names", type=str, nargs=-1)
|
||||
def delete(names: list[str]) -> None:
|
||||
"""Delete one or more WVD (Widevine Device) files from the WVDs Directory."""
|
||||
log = logging.getLogger("wvd")
|
||||
for name in names:
|
||||
path = (config.directories.wvds / name).with_suffix(".wvd")
|
||||
if not path.exists():
|
||||
log.error(f"No WVD file exists by the name '{name}'...")
|
||||
continue
|
||||
|
||||
answer = Prompt.ask(
|
||||
f"[red]Deleting '{name}'[/], are you sure you want to continue?",
|
||||
choices=["y", "n"],
|
||||
default="n",
|
||||
console=console,
|
||||
)
|
||||
if answer == "n":
|
||||
log.info("Aborting...")
|
||||
continue
|
||||
|
||||
Path.unlink(path)
|
||||
log.info(f"Deleted {name}")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("path", type=Path)
|
||||
def parse(path: Path) -> None:
|
||||
"""
|
||||
Parse a .WVD Widevine Device file to check information.
|
||||
Relative paths are relative to the WVDs directory.
|
||||
"""
|
||||
try:
|
||||
named = not path.suffix and path.relative_to(Path(""))
|
||||
except ValueError:
|
||||
named = False
|
||||
if named:
|
||||
path = config.directories.wvds / f"{path.name}.wvd"
|
||||
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
if not path.exists():
|
||||
console.log(f"[bright_blue]{path.absolute()}[/] does not exist...")
|
||||
return
|
||||
|
||||
device = Device.load(path)
|
||||
|
||||
log.info(f"System ID: {device.system_id}")
|
||||
log.info(f"Security Level: {device.security_level}")
|
||||
log.info(f"Type: {device.type}")
|
||||
log.info(f"Flags: {device.flags}")
|
||||
log.info(f"Private Key: {bool(device.private_key)}")
|
||||
log.info(f"Client ID: {bool(device.client_id)}")
|
||||
log.info(f"VMP: {bool(device.client_id.vmp_data)}")
|
||||
|
||||
log.info("Client ID:")
|
||||
log.info(device.client_id)
|
||||
|
||||
log.info("VMP:")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes = FileHashes()
|
||||
file_hashes.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(str(file_hashes))
|
||||
else:
|
||||
log.info("None")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("wvd_paths", type=Path, nargs=-1)
|
||||
@click.argument("out_dir", type=Path, nargs=1)
|
||||
def dump(wvd_paths: list[Path], out_dir: Path) -> None:
|
||||
"""
|
||||
Extract data from a .WVD Widevine Device file to a folder structure.
|
||||
|
||||
If the path is relative, with no file extension, it will dump the WVD in the WVDs
|
||||
directory.
|
||||
"""
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
if 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:
|
||||
named = not wvd_path.suffix and wvd_path.relative_to(Path(""))
|
||||
except ValueError:
|
||||
named = False
|
||||
if named:
|
||||
wvd_path = config.directories.wvds / f"{wvd_path.stem}.wvd"
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log.info(f"Dumping: {wvd_path}")
|
||||
device = Device.load(wvd_path)
|
||||
|
||||
log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
|
||||
log.info(f"Saving to: {out_path}")
|
||||
|
||||
device_meta = {
|
||||
"wvd": {"device_type": device.type.name, "security_level": device.security_level, **device.flags},
|
||||
"client_info": {},
|
||||
"capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"],
|
||||
}
|
||||
for client_info in device.client_id.client_info:
|
||||
device_meta["client_info"][client_info.name] = client_info.value
|
||||
|
||||
device_meta_path = out_path / "metadata.yml"
|
||||
device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8")
|
||||
log.info(" + Device Metadata")
|
||||
|
||||
if device.private_key:
|
||||
private_key_path = out_path / "private_key.pem"
|
||||
private_key_path.write_text(data=device.private_key.export_key().decode(), encoding="utf8")
|
||||
private_key_path.with_suffix(".der").write_bytes(device.private_key.export_key(format="DER"))
|
||||
log.info(" + Private Key")
|
||||
else:
|
||||
log.warning(" - No Private Key available")
|
||||
|
||||
if device.client_id:
|
||||
client_id_path = out_path / "client_id.bin"
|
||||
client_id_path.write_bytes(device.client_id.SerializeToString())
|
||||
log.info(" + Client ID")
|
||||
else:
|
||||
log.warning(" - No Client ID available")
|
||||
|
||||
if device.client_id.vmp_data:
|
||||
vmp_path = out_path / "vmp.bin"
|
||||
vmp_path.write_bytes(device.client_id.vmp_data)
|
||||
log.info(" + VMP (File Hashes)")
|
||||
else:
|
||||
log.info(" - No VMP (File Hashes) available")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("name", type=str)
|
||||
@click.argument("private_key", type=Path)
|
||||
@click.argument("client_id", type=Path)
|
||||
@click.argument("file_hashes", type=Path, required=False)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--type",
|
||||
"type_",
|
||||
type=click.Choice([x.name for x in DeviceTypes], case_sensitive=False),
|
||||
default="Android",
|
||||
help="Device Type",
|
||||
)
|
||||
@click.option("-l", "--level", type=click.IntRange(1, 3), default=1, help="Device Security Level")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def new(
|
||||
ctx: click.Context,
|
||||
name: str,
|
||||
private_key: Path,
|
||||
client_id: Path,
|
||||
file_hashes: Optional[Path],
|
||||
type_: str,
|
||||
level: int,
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Create a new .WVD Widevine provision file.
|
||||
|
||||
name: The origin device name of the provided data. e.g. `Nexus 6P`. You do not need to
|
||||
specify the security level, that will be done automatically.
|
||||
private_key: A PEM file of a Device's private key.
|
||||
client_id: A binary blob file which follows the Widevine ClientIdentification protobuf
|
||||
schema.
|
||||
file_hashes: A binary blob file with follows the Widevine FileHashes protobuf schema.
|
||||
Also known as VMP as it's used for VMP (Verified Media Path) assurance.
|
||||
"""
|
||||
try:
|
||||
# TODO: Remove need for name, create name based on Client IDs ClientInfo values
|
||||
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||
except UnidecodeError as e:
|
||||
raise click.UsageError(f"name: Failed to sanitize name, {e}", ctx)
|
||||
if not name:
|
||||
raise click.UsageError("name: Empty after sanitizing, please make sure the name is valid.", ctx)
|
||||
if not private_key.is_file():
|
||||
raise click.UsageError("private_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not client_id.is_file():
|
||||
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if file_hashes and not file_hashes.is_file():
|
||||
raise click.UsageError("file_hashes: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
device = Device(
|
||||
type_=DeviceTypes[type_.upper()],
|
||||
security_level=level,
|
||||
flags=None,
|
||||
private_key=private_key.read_bytes(),
|
||||
client_id=client_id.read_bytes(),
|
||||
)
|
||||
|
||||
if file_hashes:
|
||||
device.client_id.vmp_data = file_hashes.read_bytes()
|
||||
|
||||
out_path = (output or config.directories.wvds) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||
device.dump(out_path)
|
||||
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
log.info(f"Created binary WVD file, {out_path.name}")
|
||||
log.info(f" + Saved to: {out_path.absolute()}")
|
||||
|
||||
log.info(f"System ID: {device.system_id}")
|
||||
log.info(f"Security Level: {device.security_level}")
|
||||
log.info(f"Type: {device.type}")
|
||||
log.info(f"Flags: {device.flags}")
|
||||
log.info(f"Private Key: {bool(device.private_key)}")
|
||||
log.info(f"Client ID: {bool(device.client_id)}")
|
||||
log.info(f"VMP: {bool(device.client_id.vmp_data)}")
|
||||
|
||||
log.info("Client ID:")
|
||||
log.info(device.client_id)
|
||||
|
||||
log.info("VMP:")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes = FileHashes()
|
||||
file_hashes.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(str(file_hashes))
|
||||
else:
|
||||
log.info("None")
|
||||
1
unshackle/core/__init__.py
Normal file
1
unshackle/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "1.0.1"
|
||||
92
unshackle/core/__main__.py
Normal file
92
unshackle/core/__main__.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import atexit
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import urllib3
|
||||
from rich import traceback
|
||||
from rich.console import Group
|
||||
from rich.padding import Padding
|
||||
from rich.text import Text
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
from unshackle.core import __version__
|
||||
from unshackle.core.commands import Commands
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import ComfyRichHandler, console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.utilities import rotate_log_file
|
||||
|
||||
LOGGING_PATH = None
|
||||
|
||||
|
||||
@click.command(cls=Commands, invoke_without_command=True, context_settings=context_settings)
|
||||
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
||||
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs.")
|
||||
@click.option(
|
||||
"--log",
|
||||
"log_path",
|
||||
type=Path,
|
||||
default=config.directories.logs / config.filenames.log,
|
||||
help="Log path (or filename). Path can contain the following f-string args: {name} {time}.",
|
||||
)
|
||||
def main(version: bool, debug: bool, log_path: Path) -> None:
|
||||
"""unshackle—Modular Movie, TV, and Music Archival Software."""
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if debug else logging.INFO,
|
||||
format="%(message)s",
|
||||
handlers=[
|
||||
ComfyRichHandler(
|
||||
show_time=False,
|
||||
show_path=debug,
|
||||
console=console,
|
||||
rich_tracebacks=True,
|
||||
tracebacks_suppress=[click],
|
||||
log_renderer=console._log_render, # noqa
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if log_path:
|
||||
global LOGGING_PATH
|
||||
console.record = True
|
||||
new_log_path = rotate_log_file(log_path)
|
||||
LOGGING_PATH = new_log_path
|
||||
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
traceback.install(console=console, width=80, suppress=[click])
|
||||
|
||||
console.print(
|
||||
Padding(
|
||||
Group(
|
||||
Text(
|
||||
r"▄• ▄▌ ▐ ▄ .▄▄ · ▄ .▄ ▄▄▄· ▄▄· ▄ •▄ ▄▄▌ ▄▄▄ ." + "\n"
|
||||
r"█▪██▌•█▌▐█▐█ ▀. ██▪▐█▐█ ▀█ ▐█ ▌▪█▌▄▌▪██• ▀▄.▀·" + "\n"
|
||||
r"█▌▐█▌▐█▐▐▌▄▀▀▀█▄██▀▐█▄█▀▀█ ██ ▄▄▐▀▀▄·██▪ ▐▀▀▪▄" + "\n"
|
||||
r"▐█▄█▌██▐█▌▐█▄▪▐███▌▐▀▐█ ▪▐▌▐███▌▐█.█▌▐█▌▐▌▐█▄▄▌" + "\n"
|
||||
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ " + "\n",
|
||||
style="ascii.art",
|
||||
),
|
||||
f"v[repr.number]{__version__}[/] - {datetime.now().year} - sp4rk.y",
|
||||
),
|
||||
(1, 11, 1, 10),
|
||||
expand=True,
|
||||
),
|
||||
justify="center",
|
||||
)
|
||||
|
||||
if version:
|
||||
return
|
||||
|
||||
|
||||
@atexit.register
|
||||
def save_log():
|
||||
if console.record and LOGGING_PATH:
|
||||
# TODO: Currently semi-bust. Everything that refreshes gets duplicated.
|
||||
console.save_text(LOGGING_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
unshackle/core/binaries.py
Normal file
50
unshackle/core/binaries.py
Normal file
@@ -0,0 +1,50 @@
|
||||
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")
|
||||
N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re")
|
||||
|
||||
|
||||
__all__ = (
|
||||
"FFMPEG",
|
||||
"FFProbe",
|
||||
"FFPlay",
|
||||
"SubtitleEdit",
|
||||
"ShakaPackager",
|
||||
"Aria2",
|
||||
"CCExtractor",
|
||||
"HolaProxy",
|
||||
"MPV",
|
||||
"Caddy",
|
||||
"N_m3u8DL_RE",
|
||||
"find",
|
||||
)
|
||||
156
unshackle/core/cacher.py
Normal file
156
unshackle/core/cacher.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zlib
|
||||
from datetime import datetime, timedelta
|
||||
from os import stat_result
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import jsonpickle
|
||||
import jwt
|
||||
|
||||
from unshackle.core.config import config
|
||||
|
||||
EXP_T = Union[datetime, str, int, float]
|
||||
|
||||
|
||||
class Cacher:
|
||||
"""Cacher for Services to get and set arbitrary data with expiration dates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_tag: str,
|
||||
key: Optional[str] = None,
|
||||
version: Optional[int] = 1,
|
||||
data: Optional[Any] = None,
|
||||
expiration: Optional[datetime] = None,
|
||||
) -> None:
|
||||
self.service_tag = service_tag
|
||||
self.key = key
|
||||
self.version = version
|
||||
self.data = data or {}
|
||||
self.expiration = expiration
|
||||
|
||||
if self.expiration and self.expired:
|
||||
# if its expired, remove the data for safety and delete cache file
|
||||
self.data = None
|
||||
self.path.unlink()
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.data)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Get the path at which the cache will be read and written."""
|
||||
return (config.directories.cache / self.service_tag / self.key).with_suffix(".json")
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
return self.expiration and self.expiration < datetime.now()
|
||||
|
||||
def get(self, key: str, version: int = 1) -> Cacher:
|
||||
"""
|
||||
Get Cached data for the Service by Key.
|
||||
:param key: the filename to save the data to, should be url-safe.
|
||||
:param version: the config data version you expect to use.
|
||||
:returns: Cache object containing the cached data or None if the file does not exist.
|
||||
"""
|
||||
cache = Cacher(self.service_tag, key, version)
|
||||
if cache.path.is_file():
|
||||
data = jsonpickle.loads(cache.path.read_text(encoding="utf8"))
|
||||
payload = data.copy()
|
||||
del payload["crc32"]
|
||||
checksum = data["crc32"]
|
||||
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||
if calculated != checksum:
|
||||
raise ValueError(
|
||||
f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}"
|
||||
)
|
||||
cache.data = data["data"]
|
||||
cache.expiration = data["expiration"]
|
||||
cache.version = data["version"]
|
||||
if cache.version != version:
|
||||
raise ValueError(
|
||||
f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}"
|
||||
)
|
||||
return cache
|
||||
|
||||
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
|
||||
"""
|
||||
Set Cached data for the Service by Key.
|
||||
:param data: absolutely anything including None.
|
||||
:param expiration: when the data expires, optional. Can be ISO 8601, seconds
|
||||
til expiration, unix timestamp, or a datetime object.
|
||||
:returns: the data provided for quick wrapping of functions or vars.
|
||||
"""
|
||||
self.data = data
|
||||
|
||||
if not expiration:
|
||||
try:
|
||||
expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"]
|
||||
except jwt.DecodeError:
|
||||
pass
|
||||
|
||||
self.expiration = self._resolve_datetime(expiration) if expiration else None
|
||||
|
||||
payload = {"data": self.data, "expiration": self.expiration, "version": self.version}
|
||||
payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(jsonpickle.dumps(payload))
|
||||
|
||||
return self.data
|
||||
|
||||
def stat(self) -> stat_result:
|
||||
"""
|
||||
Get Cache file OS Stat data like Creation Time, Modified Time, and such.
|
||||
:returns: an os.stat_result tuple
|
||||
"""
|
||||
return self.path.stat()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_datetime(timestamp: EXP_T) -> datetime:
|
||||
"""
|
||||
Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime.
|
||||
|
||||
Examples:
|
||||
>>> now = datetime.now()
|
||||
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||
>>> iso8601 = now.isoformat()
|
||||
'2022-06-27T09:49:13.657208'
|
||||
>>> Cacher._resolve_datetime(iso8601)
|
||||
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||
>>> Cacher._resolve_datetime(iso8601 + "Z")
|
||||
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||
>>> Cacher._resolve_datetime(3600)
|
||||
datetime.datetime(2022, 6, 27, 10, 52, 50, 657208)
|
||||
>>> Cacher._resolve_datetime('3600')
|
||||
datetime.datetime(2022, 6, 27, 10, 52, 51, 657208)
|
||||
>>> Cacher._resolve_datetime(7800.113)
|
||||
datetime.datetime(2022, 6, 27, 11, 59, 13, 770208)
|
||||
|
||||
In the int/float examples you may notice that it did not return now + 3600 seconds
|
||||
but rather something a bit more than that. This is because it did not resolve 3600
|
||||
seconds from the `now` variable but from right now as the function was called.
|
||||
"""
|
||||
if isinstance(timestamp, datetime):
|
||||
return timestamp
|
||||
if isinstance(timestamp, str):
|
||||
if timestamp.endswith("Z"):
|
||||
# fromisoformat doesn't accept the final Z
|
||||
timestamp = timestamp.split("Z")[0]
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp)
|
||||
except ValueError:
|
||||
timestamp = float(timestamp)
|
||||
try:
|
||||
if len(str(int(timestamp))) == 13: # JS-style timestamp
|
||||
timestamp /= 1000
|
||||
timestamp = datetime.fromtimestamp(timestamp)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unrecognized Timestamp value {timestamp!r}")
|
||||
if timestamp < datetime.now():
|
||||
# timestamp is likely an amount of seconds til expiration
|
||||
# or, it's an already expired timestamp which is unlikely
|
||||
timestamp = timestamp + timedelta(seconds=datetime.now().timestamp())
|
||||
return timestamp
|
||||
3
unshackle/core/cdm/__init__.py
Normal file
3
unshackle/core/cdm/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
||||
|
||||
__all__ = ["DecryptLabsRemoteCDM"]
|
||||
143
unshackle/core/cdm/decrypt_labs_remote_cdm.py
Normal file
143
unshackle/core/cdm/decrypt_labs_remote_cdm.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import base64
|
||||
import secrets
|
||||
from typing import Optional, Type, Union
|
||||
from uuid import UUID
|
||||
|
||||
import requests
|
||||
from pywidevine import PSSH, Device, DeviceTypes, Key, RemoteCdm
|
||||
from pywidevine.license_protocol_pb2 import SignedDrmCertificate, SignedMessage
|
||||
|
||||
# Copyright 2024 by DevYukine.
|
||||
|
||||
|
||||
class DecryptLabsRemoteCDM(RemoteCdm):
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Union[DeviceTypes, str],
|
||||
system_id: int,
|
||||
security_level: int,
|
||||
host: str,
|
||||
secret: str,
|
||||
device_name: str,
|
||||
service_name: str,
|
||||
):
|
||||
self.response_counter = 0
|
||||
self.pssh = None
|
||||
self.api_session_ids = {}
|
||||
self.license_request = None
|
||||
self.service_name = service_name
|
||||
self.keys = {}
|
||||
try:
|
||||
super().__init__(device_type, system_id, security_level, host, secret, device_name)
|
||||
except Exception:
|
||||
pass
|
||||
self.req_session = requests.Session()
|
||||
self.req_session.headers.update({"decrypt-labs-api-key": secret})
|
||||
|
||||
@classmethod
|
||||
def from_device(cls, device: Device) -> Type["DecryptLabsRemoteCDM"]:
|
||||
raise NotImplementedError("You cannot load a DecryptLabsRemoteCDM from a local Device file.")
|
||||
|
||||
def open(self) -> bytes:
|
||||
# We stub this method to return a random session ID for now, later we save the api session id and resolve by our random generated one.
|
||||
return bytes.fromhex(secrets.token_hex(16))
|
||||
|
||||
def close(self, session_id: bytes) -> None:
|
||||
# We stub this method to do nothing.
|
||||
pass
|
||||
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
||||
if isinstance(certificate, bytes):
|
||||
certificate = base64.b64encode(certificate).decode()
|
||||
|
||||
# certificate needs to be base64 to be sent off to the API.
|
||||
# it needs to intentionally be kept as base64 encoded SignedMessage.
|
||||
|
||||
self.req_session.signed_device_certificate = certificate
|
||||
self.req_session.privacy_mode = True
|
||||
|
||||
return "success"
|
||||
|
||||
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
||||
raise NotImplementedError("This method is not implemented in this CDM")
|
||||
|
||||
def get_license_challenge(
|
||||
self, session_id: bytes, pssh: PSSH, license_type: str = "STREAMING", privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
self.pssh = pssh
|
||||
|
||||
res = self.session(
|
||||
self.host + "/get-request",
|
||||
{
|
||||
"init_data": self.pssh.dumps(),
|
||||
"service_certificate": self.req_session.signed_device_certificate,
|
||||
"scheme": "widevine",
|
||||
"service": self.service_name,
|
||||
},
|
||||
)
|
||||
|
||||
self.license_request = res["challenge"]
|
||||
self.api_session_ids[session_id] = res["session_id"]
|
||||
|
||||
return base64.b64decode(self.license_request)
|
||||
|
||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||
session_id_api = self.api_session_ids[session_id]
|
||||
if session_id not in self.keys:
|
||||
self.keys[session_id] = []
|
||||
session_keys = self.keys[session_id]
|
||||
|
||||
if isinstance(license_message, dict) and "keys" in license_message:
|
||||
session_keys.extend(
|
||||
[
|
||||
Key(kid=Key.kid_to_uuid(x["kid"]), type_=x.get("type", "CONTENT"), key=bytes.fromhex(x["key"]))
|
||||
for x in license_message["keys"]
|
||||
]
|
||||
)
|
||||
|
||||
else:
|
||||
res = self.session(
|
||||
self.host + "/decrypt-response",
|
||||
{
|
||||
"session_id": session_id_api,
|
||||
"init_data": self.pssh.dumps(),
|
||||
"license_request": self.license_request,
|
||||
"license_response": license_message,
|
||||
"scheme": "widevine",
|
||||
},
|
||||
)
|
||||
|
||||
original_keys = res["keys"].replace("\n", " ")
|
||||
keys_separated = original_keys.split("--key ")
|
||||
formatted_keys = []
|
||||
for k in keys_separated:
|
||||
if ":" in k:
|
||||
key = k.strip()
|
||||
formatted_keys.append(key)
|
||||
for keys in formatted_keys:
|
||||
session_keys.append(
|
||||
(
|
||||
Key(
|
||||
kid=UUID(bytes=bytes.fromhex(keys.split(":")[0])),
|
||||
type_="CONTENT",
|
||||
key=bytes.fromhex(keys.split(":")[1]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
||||
return self.keys[session_id]
|
||||
|
||||
def session(self, url, data, retries=3):
|
||||
res = self.req_session.post(url, json=data).json()
|
||||
|
||||
if res.get("message") != "success":
|
||||
if "License Response Decryption Process Failed at the very beginning" in res.get("Error", ""):
|
||||
if retries > 0:
|
||||
return self.session(url, data, retries=retries - 1)
|
||||
else:
|
||||
raise ValueError(f"CDM API returned an error: {res['Error']}")
|
||||
else:
|
||||
raise ValueError(f"CDM API returned an error: {res['Error']}")
|
||||
|
||||
return res
|
||||
35
unshackle/core/commands.py
Normal file
35
unshackle/core/commands.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.utilities import import_module_by_path
|
||||
|
||||
_COMMANDS = sorted(
|
||||
(path for path in config.directories.commands.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem
|
||||
)
|
||||
|
||||
_MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in _COMMANDS}
|
||||
|
||||
|
||||
class Commands(click.MultiCommand):
|
||||
"""Lazy-loaded command group of project commands."""
|
||||
|
||||
def list_commands(self, ctx: click.Context) -> list[str]:
|
||||
"""Returns a list of command names from the command filenames."""
|
||||
return [x.stem for x in _COMMANDS]
|
||||
|
||||
def get_command(self, ctx: click.Context, name: str) -> Optional[click.Command]:
|
||||
"""Load the command code and return the main click command function."""
|
||||
module = _MODULES.get(name)
|
||||
if not module:
|
||||
raise click.ClickException(f"Unable to find command by the name '{name}'")
|
||||
|
||||
if hasattr(module, "cli"):
|
||||
return module.cli
|
||||
|
||||
return module
|
||||
|
||||
|
||||
# Hide direct access to commands from quick import form, they shouldn't be accessed directly
|
||||
__all__ = ("Commands",)
|
||||
117
unshackle/core/config.py
Normal file
117
unshackle/core/config.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from appdirs import AppDirs
|
||||
|
||||
|
||||
class Config:
|
||||
class _Directories:
|
||||
# default directories, do not modify here, set via config
|
||||
app_dirs = AppDirs("unshackle", False)
|
||||
core_dir = Path(__file__).resolve().parent
|
||||
namespace_dir = core_dir.parent
|
||||
commands = namespace_dir / "commands"
|
||||
services = namespace_dir / "services"
|
||||
vaults = namespace_dir / "vaults"
|
||||
fonts = namespace_dir / "fonts"
|
||||
user_configs = core_dir.parent
|
||||
data = core_dir.parent
|
||||
downloads = core_dir.parent.parent / "downloads"
|
||||
temp = core_dir.parent.parent / "temp"
|
||||
cache = data / "cache"
|
||||
cookies = data / "cookies"
|
||||
logs = data / "logs"
|
||||
wvds = data / "WVDs"
|
||||
prds = data / "PRDs"
|
||||
dcsl = data / "DCSL"
|
||||
|
||||
class _Filenames:
|
||||
# default filenames, do not modify here, set via config
|
||||
log = "unshackle_{name}_{time}.log" # Directories.logs
|
||||
config = "config.yaml" # Directories.services / tag
|
||||
root_config = "unshackle.yaml" # Directories.user_configs
|
||||
chapters = "Chapters_{title}_{random}.txt" # Directories.temp
|
||||
subtitle = "Subtitle_{id}_{language}.srt" # Directories.temp
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
self.dl: dict = kwargs.get("dl") or {}
|
||||
self.aria2c: dict = kwargs.get("aria2c") or {}
|
||||
self.n_m3u8dl_re: dict = kwargs.get("n_m3u8dl_re") or {}
|
||||
self.cdm: dict = kwargs.get("cdm") or {}
|
||||
self.chapter_fallback_name: str = kwargs.get("chapter_fallback_name") or ""
|
||||
self.curl_impersonate: dict = kwargs.get("curl_impersonate") or {}
|
||||
self.remote_cdm: list[dict] = kwargs.get("remote_cdm") or []
|
||||
self.credentials: dict = kwargs.get("credentials") or {}
|
||||
|
||||
self.directories = self._Directories()
|
||||
for name, path in (kwargs.get("directories") or {}).items():
|
||||
if name.lower() in ("app_dirs", "core_dir", "namespace_dir", "user_configs", "data"):
|
||||
# these must not be modified by the user
|
||||
continue
|
||||
setattr(self.directories, name, Path(path).expanduser())
|
||||
|
||||
downloader_cfg = kwargs.get("downloader") or "requests"
|
||||
if isinstance(downloader_cfg, dict):
|
||||
self.downloader_map = {k.upper(): v for k, v in downloader_cfg.items()}
|
||||
self.downloader = self.downloader_map.get("DEFAULT", "requests")
|
||||
else:
|
||||
self.downloader_map = {}
|
||||
self.downloader = downloader_cfg
|
||||
|
||||
self.filenames = self._Filenames()
|
||||
for name, filename in (kwargs.get("filenames") or {}).items():
|
||||
setattr(self.filenames, name, filename)
|
||||
|
||||
self.headers: dict = kwargs.get("headers") or {}
|
||||
self.key_vaults: list[dict[str, Any]] = kwargs.get("key_vaults", [])
|
||||
self.muxing: dict = kwargs.get("muxing") or {}
|
||||
self.nordvpn: dict = kwargs.get("nordvpn") or {}
|
||||
self.proxy_providers: dict = kwargs.get("proxy_providers") or {}
|
||||
self.serve: dict = kwargs.get("serve") or {}
|
||||
self.services: dict = kwargs.get("services") or {}
|
||||
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", True)
|
||||
self.tag: str = kwargs.get("tag") or ""
|
||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path) -> Config:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file path ({path}) was not found")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Config file path ({path}) is not to a file.")
|
||||
return cls(**yaml.safe_load(path.read_text(encoding="utf8")) or {})
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
POSSIBLE_CONFIG_PATHS = (
|
||||
# The unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/unshackle)
|
||||
Config._Directories.namespace_dir / Config._Filenames.root_config,
|
||||
# The Parent Folder to the unshackle 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%/unshackle)
|
||||
Config._Directories.user_configs / Config._Filenames.root_config,
|
||||
)
|
||||
|
||||
|
||||
def get_config_path() -> Optional[Path]:
|
||||
"""
|
||||
Get Path to Config from any one of the possible locations.
|
||||
|
||||
Returns None if no config file could be found.
|
||||
"""
|
||||
for path in POSSIBLE_CONFIG_PATHS:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
config_path = get_config_path()
|
||||
if config_path:
|
||||
config = Config.from_yaml(config_path)
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
__all__ = ("config",)
|
||||
351
unshackle/core/console.py
Normal file
351
unshackle/core/console.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import ModuleType
|
||||
from typing import IO, Callable, Iterable, List, Literal, Mapping, Optional, Union
|
||||
|
||||
from rich._log_render import FormatTimeCallable, LogRender
|
||||
from rich.console import Console, ConsoleRenderable, HighlighterType, RenderableType
|
||||
from rich.emoji import EmojiVariant
|
||||
from rich.highlighter import Highlighter, ReprHighlighter
|
||||
from rich.live import Live
|
||||
from rich.logging import RichHandler
|
||||
from rich.padding import Padding, PaddingDimensions
|
||||
from rich.status import Status
|
||||
from rich.style import StyleType
|
||||
from rich.table import Table
|
||||
from rich.text import Text, TextType
|
||||
from rich.theme import Theme
|
||||
|
||||
from unshackle.core.config import config
|
||||
|
||||
|
||||
class ComfyLogRenderer(LogRender):
|
||||
def __call__(
|
||||
self,
|
||||
console: "Console",
|
||||
renderables: Iterable["ConsoleRenderable"],
|
||||
log_time: Optional[datetime] = None,
|
||||
time_format: Optional[Union[str, FormatTimeCallable]] = None,
|
||||
level: TextType = "",
|
||||
path: Optional[str] = None,
|
||||
line_no: Optional[int] = None,
|
||||
link_path: Optional[str] = None,
|
||||
) -> "Table":
|
||||
from rich.containers import Renderables
|
||||
|
||||
output = Table.grid(padding=(0, 5), pad_edge=True)
|
||||
output.expand = True
|
||||
if self.show_time:
|
||||
output.add_column(style="log.time")
|
||||
if self.show_level:
|
||||
output.add_column(style="log.level", width=self.level_width)
|
||||
output.add_column(ratio=1, style="log.message", overflow="fold")
|
||||
if self.show_path and path:
|
||||
output.add_column(style="log.path")
|
||||
row: List["RenderableType"] = []
|
||||
if self.show_time:
|
||||
log_time = log_time or console.get_datetime()
|
||||
time_format = time_format or self.time_format
|
||||
if callable(time_format):
|
||||
log_time_display = time_format(log_time)
|
||||
else:
|
||||
log_time_display = Text(log_time.strftime(time_format))
|
||||
if log_time_display == self._last_time and self.omit_repeated_times:
|
||||
row.append(Text(" " * len(log_time_display)))
|
||||
else:
|
||||
row.append(log_time_display)
|
||||
self._last_time = log_time_display
|
||||
if self.show_level:
|
||||
row.append(level)
|
||||
|
||||
row.append(Renderables(renderables))
|
||||
if self.show_path and path:
|
||||
path_text = Text()
|
||||
path_text.append(path, style=f"link file://{link_path}" if link_path else "")
|
||||
if line_no:
|
||||
path_text.append(":")
|
||||
path_text.append(
|
||||
f"{line_no}",
|
||||
style=f"link file://{link_path}#{line_no}" if link_path else "",
|
||||
)
|
||||
row.append(path_text)
|
||||
|
||||
output.add_row(*row)
|
||||
return output
|
||||
|
||||
|
||||
class ComfyRichHandler(RichHandler):
|
||||
def __init__(
|
||||
self,
|
||||
level: Union[int, str] = logging.NOTSET,
|
||||
console: Optional[Console] = None,
|
||||
*,
|
||||
show_time: bool = True,
|
||||
omit_repeated_times: bool = True,
|
||||
show_level: bool = True,
|
||||
show_path: bool = True,
|
||||
enable_link_path: bool = True,
|
||||
highlighter: Optional[Highlighter] = None,
|
||||
markup: bool = False,
|
||||
rich_tracebacks: bool = False,
|
||||
tracebacks_width: Optional[int] = None,
|
||||
tracebacks_extra_lines: int = 3,
|
||||
tracebacks_theme: Optional[str] = None,
|
||||
tracebacks_word_wrap: bool = True,
|
||||
tracebacks_show_locals: bool = False,
|
||||
tracebacks_suppress: Iterable[Union[str, ModuleType]] = (),
|
||||
locals_max_length: int = 10,
|
||||
locals_max_string: int = 80,
|
||||
log_time_format: Union[str, FormatTimeCallable] = "[%x %X]",
|
||||
keywords: Optional[List[str]] = None,
|
||||
log_renderer: Optional[LogRender] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
level=level,
|
||||
console=console,
|
||||
show_time=show_time,
|
||||
omit_repeated_times=omit_repeated_times,
|
||||
show_level=show_level,
|
||||
show_path=show_path,
|
||||
enable_link_path=enable_link_path,
|
||||
highlighter=highlighter,
|
||||
markup=markup,
|
||||
rich_tracebacks=rich_tracebacks,
|
||||
tracebacks_width=tracebacks_width,
|
||||
tracebacks_extra_lines=tracebacks_extra_lines,
|
||||
tracebacks_theme=tracebacks_theme,
|
||||
tracebacks_word_wrap=tracebacks_word_wrap,
|
||||
tracebacks_show_locals=tracebacks_show_locals,
|
||||
tracebacks_suppress=tracebacks_suppress,
|
||||
locals_max_length=locals_max_length,
|
||||
locals_max_string=locals_max_string,
|
||||
log_time_format=log_time_format,
|
||||
keywords=keywords,
|
||||
)
|
||||
if log_renderer:
|
||||
self._log_render = log_renderer
|
||||
|
||||
|
||||
class ComfyConsole(Console):
|
||||
"""A comfy high level console interface.
|
||||
|
||||
Args:
|
||||
color_system (str, optional): The color system supported by your terminal,
|
||||
either ``"standard"``, ``"256"`` or ``"truecolor"``. Leave as ``"auto"`` to autodetect.
|
||||
force_terminal (Optional[bool], optional): Enable/disable terminal control codes, or None to auto-detect
|
||||
terminal. Defaults to None.
|
||||
force_jupyter (Optional[bool], optional): Enable/disable Jupyter rendering, or None to auto-detect Jupyter.
|
||||
Defaults to None.
|
||||
force_interactive (Optional[bool], optional): Enable/disable interactive mode, or None to auto-detect.
|
||||
Defaults to None.
|
||||
soft_wrap (Optional[bool], optional): Set soft wrap default on print method. Defaults to False.
|
||||
theme (Theme, optional): An optional style theme object, or ``None`` for default theme.
|
||||
stderr (bool, optional): Use stderr rather than stdout if ``file`` is not specified. Defaults to False.
|
||||
file (IO, optional): A file object where the console should write to. Defaults to stdout.
|
||||
quiet (bool, Optional): Boolean to suppress all output. Defaults to False.
|
||||
width (int, optional): The width of the terminal. Leave as default to auto-detect width.
|
||||
height (int, optional): The height of the terminal. Leave as default to auto-detect height.
|
||||
style (StyleType, optional): Style to apply to all output, or None for no style. Defaults to None.
|
||||
no_color (Optional[bool], optional): Enabled no color mode, or None to auto-detect. Defaults to None.
|
||||
tab_size (int, optional): Number of spaces used to replace a tab character. Defaults to 8.
|
||||
record (bool, optional): Boolean to enable recording of terminal output,
|
||||
required to call :meth:`export_html`, :meth:`export_svg`, and :meth:`export_text`. Defaults to False.
|
||||
markup (bool, optional): Boolean to enable :ref:`console_markup`. Defaults to True.
|
||||
emoji (bool, optional): Enable emoji code. Defaults to True.
|
||||
emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.
|
||||
highlight (bool, optional): Enable automatic highlighting. Defaults to True.
|
||||
log_time (bool, optional): Boolean to enable logging of time by :meth:`log` methods. Defaults to True.
|
||||
log_path (bool, optional): Boolean to enable the logging of the caller by :meth:`log`. Defaults to True.
|
||||
log_time_format (Union[str, TimeFormatterCallable], optional): If ``log_time`` is enabled, either string for
|
||||
strftime or callable that formats the time. Defaults to "[%X] ".
|
||||
highlighter (HighlighterType, optional): Default highlighter.
|
||||
legacy_windows (bool, optional): Enable legacy Windows mode, or ``None`` to auto-detect. Defaults to ``None``.
|
||||
safe_box (bool, optional): Restrict box options that don't render on legacy Windows.
|
||||
get_datetime (Callable[[], datetime], optional): Callable that gets the current time as a datetime.datetime
|
||||
object (used by Console.log), or None for datetime.now.
|
||||
get_time (Callable[[], time], optional): Callable that gets the current time in seconds, default uses
|
||||
time.monotonic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
color_system: Optional[Literal["auto", "standard", "256", "truecolor", "windows"]] = "auto",
|
||||
force_terminal: Optional[bool] = None,
|
||||
force_jupyter: Optional[bool] = None,
|
||||
force_interactive: Optional[bool] = None,
|
||||
soft_wrap: bool = False,
|
||||
theme: Optional[Theme] = None,
|
||||
stderr: bool = False,
|
||||
file: Optional[IO[str]] = None,
|
||||
quiet: bool = False,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
style: Optional[StyleType] = None,
|
||||
no_color: Optional[bool] = None,
|
||||
tab_size: int = 8,
|
||||
record: bool = False,
|
||||
markup: bool = True,
|
||||
emoji: bool = True,
|
||||
emoji_variant: Optional[EmojiVariant] = None,
|
||||
highlight: bool = True,
|
||||
log_time: bool = True,
|
||||
log_path: bool = True,
|
||||
log_time_format: Union[str, FormatTimeCallable] = "[%X]",
|
||||
highlighter: Optional["HighlighterType"] = ReprHighlighter(),
|
||||
legacy_windows: Optional[bool] = None,
|
||||
safe_box: bool = True,
|
||||
get_datetime: Optional[Callable[[], datetime]] = None,
|
||||
get_time: Optional[Callable[[], float]] = None,
|
||||
_environ: Optional[Mapping[str, str]] = None,
|
||||
log_renderer: Optional[LogRender] = None,
|
||||
):
|
||||
super().__init__(
|
||||
color_system=color_system,
|
||||
force_terminal=force_terminal,
|
||||
force_jupyter=force_jupyter,
|
||||
force_interactive=force_interactive,
|
||||
soft_wrap=soft_wrap,
|
||||
theme=theme,
|
||||
stderr=stderr,
|
||||
file=file,
|
||||
quiet=quiet,
|
||||
width=width,
|
||||
height=height,
|
||||
style=style,
|
||||
no_color=no_color,
|
||||
tab_size=tab_size,
|
||||
record=record,
|
||||
markup=markup,
|
||||
emoji=emoji,
|
||||
emoji_variant=emoji_variant,
|
||||
highlight=highlight,
|
||||
log_time=log_time,
|
||||
log_path=log_path,
|
||||
log_time_format=log_time_format,
|
||||
highlighter=highlighter,
|
||||
legacy_windows=legacy_windows,
|
||||
safe_box=safe_box,
|
||||
get_datetime=get_datetime,
|
||||
get_time=get_time,
|
||||
_environ=_environ,
|
||||
)
|
||||
if log_renderer:
|
||||
self._log_render = log_renderer
|
||||
|
||||
def status(
|
||||
self,
|
||||
status: RenderableType,
|
||||
*,
|
||||
spinner: str = "dots",
|
||||
spinner_style: str = "status.spinner",
|
||||
speed: float = 1.0,
|
||||
refresh_per_second: float = 12.5,
|
||||
pad: PaddingDimensions = (0, 5),
|
||||
) -> Union[Live, Status]:
|
||||
"""Display a comfy status and spinner.
|
||||
|
||||
Args:
|
||||
status (RenderableType): A status renderable (str or Text typically).
|
||||
spinner (str, optional): Name of spinner animation (see python -m rich.spinner). Defaults to "dots".
|
||||
spinner_style (StyleType, optional): Style of spinner. Defaults to "status.spinner".
|
||||
speed (float, optional): Speed factor for spinner animation. Defaults to 1.0.
|
||||
refresh_per_second (float, optional): Number of refreshes per second. Defaults to 12.5.
|
||||
pad (Union[int, Tuple[int]]): Padding for top, right, bottom, and left borders.
|
||||
May be specified with 1, 2, or 4 integers (CSS style).
|
||||
|
||||
Returns:
|
||||
Status: A Status object that may be used as a context manager.
|
||||
"""
|
||||
status_renderable = super().status(
|
||||
status=status,
|
||||
spinner=spinner,
|
||||
spinner_style=spinner_style,
|
||||
speed=speed,
|
||||
refresh_per_second=refresh_per_second,
|
||||
)
|
||||
|
||||
if pad:
|
||||
top, right, bottom, left = Padding.unpack(pad)
|
||||
|
||||
renderable_width = len(status_renderable.status)
|
||||
spinner_width = len(status_renderable.renderable.text)
|
||||
status_width = spinner_width + renderable_width
|
||||
|
||||
available_width = self.width - status_width
|
||||
if available_width > right:
|
||||
# fill up the available width with padding to apply bg color
|
||||
right = available_width - right
|
||||
|
||||
padding = Padding(status_renderable, (top, right, bottom, left))
|
||||
|
||||
return Live(padding, console=self, transient=True)
|
||||
|
||||
return status_renderable
|
||||
|
||||
|
||||
catppuccin_mocha = {
|
||||
# Colors based on "CatppuccinMocha" from Gogh themes
|
||||
"bg": "rgb(30,30,46)",
|
||||
"text": "rgb(205,214,244)",
|
||||
"text2": "rgb(162,169,193)", # slightly darker
|
||||
"black": "rgb(69,71,90)",
|
||||
"bright_black": "rgb(88,91,112)",
|
||||
"red": "rgb(243,139,168)",
|
||||
"green": "rgb(166,227,161)",
|
||||
"yellow": "rgb(249,226,175)",
|
||||
"blue": "rgb(137,180,250)",
|
||||
"pink": "rgb(245,194,231)",
|
||||
"cyan": "rgb(148,226,213)",
|
||||
"gray": "rgb(166,173,200)",
|
||||
"bright_gray": "rgb(186,194,222)",
|
||||
"dark_gray": "rgb(54,54,84)",
|
||||
}
|
||||
|
||||
primary_scheme = catppuccin_mocha
|
||||
primary_scheme["none"] = primary_scheme["text"]
|
||||
primary_scheme["grey23"] = primary_scheme["black"]
|
||||
primary_scheme["magenta"] = primary_scheme["pink"]
|
||||
primary_scheme["bright_red"] = primary_scheme["red"]
|
||||
primary_scheme["bright_green"] = primary_scheme["green"]
|
||||
primary_scheme["bright_yellow"] = primary_scheme["yellow"]
|
||||
primary_scheme["bright_blue"] = primary_scheme["blue"]
|
||||
primary_scheme["bright_magenta"] = primary_scheme["pink"]
|
||||
primary_scheme["bright_cyan"] = primary_scheme["cyan"]
|
||||
if config.set_terminal_bg:
|
||||
primary_scheme["none"] += f" on {primary_scheme['bg']}"
|
||||
|
||||
custom_colors = {"ascii.art": primary_scheme["pink"]}
|
||||
if config.set_terminal_bg:
|
||||
custom_colors["ascii.art"] += f" on {primary_scheme['bg']}"
|
||||
|
||||
|
||||
console = ComfyConsole(
|
||||
log_time=False,
|
||||
log_path=False,
|
||||
width=80,
|
||||
theme=Theme(
|
||||
{
|
||||
"bar.back": primary_scheme["dark_gray"],
|
||||
"bar.complete": primary_scheme["pink"],
|
||||
"bar.finished": primary_scheme["green"],
|
||||
"bar.pulse": primary_scheme["bright_black"],
|
||||
"black": primary_scheme["black"],
|
||||
"inspect.async_def": f"italic {primary_scheme['cyan']}",
|
||||
"progress.data.speed": "dark_orange",
|
||||
"repr.number": f"bold not italic {primary_scheme['cyan']}",
|
||||
"repr.number_complex": f"bold not italic {primary_scheme['cyan']}",
|
||||
"rule.line": primary_scheme["dark_gray"],
|
||||
"rule.text": primary_scheme["pink"],
|
||||
"tree.line": primary_scheme["dark_gray"],
|
||||
"status.spinner": primary_scheme["pink"],
|
||||
"progress.spinner": primary_scheme["pink"],
|
||||
**primary_scheme,
|
||||
**custom_colors,
|
||||
}
|
||||
),
|
||||
log_renderer=ComfyLogRenderer(show_time=False, show_path=False),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ("ComfyLogRenderer", "ComfyRichHandler", "ComfyConsole", "console")
|
||||
25
unshackle/core/constants.py
Normal file
25
unshackle/core/constants.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from threading import Event
|
||||
from typing import TypeVar, Union
|
||||
|
||||
DOWNLOAD_CANCELLED = Event()
|
||||
DOWNLOAD_LICENCE_ONLY = Event()
|
||||
|
||||
DRM_SORT_MAP = ["ClearKey", "Widevine"]
|
||||
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU
|
||||
VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"}
|
||||
DYNAMIC_RANGE_MAP = {"HDR10": "HDR", "HDR10+": "HDR", "Dolby Vision": "DV"}
|
||||
AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"}
|
||||
|
||||
context_settings = dict(
|
||||
help_option_names=["-?", "-h", "--help"], # default only has --help
|
||||
max_content_width=116, # max PEP8 line-width, -4 to adjust for initial indent
|
||||
)
|
||||
|
||||
# For use in signatures of functions which take one specific type of track at a time
|
||||
# (it can't be a list that contains e.g. both Video and Audio objects)
|
||||
TrackT = TypeVar("TrackT", bound="Track") # noqa: F821
|
||||
|
||||
# For general use in lists that can contain mixed types of tracks.
|
||||
# list[Track] won't work because list is invariant.
|
||||
# TODO: Add Chapter?
|
||||
AnyTrack = Union["Video", "Audio", "Subtitle"] # noqa: F821
|
||||
87
unshackle/core/credential.py
Normal file
87
unshackle/core/credential.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class Credential:
|
||||
"""Username (or Email) and Password Credential."""
|
||||
|
||||
def __init__(self, username: str, password: str, extra: Optional[str] = None):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.extra = extra
|
||||
self.sha1 = hashlib.sha1(self.dumps().encode()).hexdigest()
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.username) and bool(self.password)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.dumps()
|
||||
|
||||
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 dumps(self) -> str:
|
||||
"""Return credential data as a string."""
|
||||
return f"{self.username}:{self.password}" + (f":{self.extra}" if self.extra else "")
|
||||
|
||||
def dump(self, path: Union[Path, str]) -> int:
|
||||
"""Write credential data to a file."""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
return path.write_text(self.dumps(), encoding="utf8")
|
||||
|
||||
def as_base64(self, with_extra: bool = False, encode_password: bool = False, encode_extra: bool = False) -> str:
|
||||
"""
|
||||
Dump Credential as a Base64-encoded string in Basic Authorization style.
|
||||
encode_password and encode_extra will also Base64-encode the password and extra respectively.
|
||||
"""
|
||||
value = f"{self.username}:"
|
||||
if encode_password:
|
||||
value += base64.b64encode(self.password.encode()).decode()
|
||||
else:
|
||||
value += self.password
|
||||
if with_extra and self.extra:
|
||||
if encode_extra:
|
||||
value += f":{base64.b64encode(self.extra.encode()).decode()}"
|
||||
else:
|
||||
value += f":{self.extra}"
|
||||
return base64.b64encode(value.encode()).decode()
|
||||
|
||||
@classmethod
|
||||
def loads(cls, text: str) -> Credential:
|
||||
"""
|
||||
Load credential from a text string.
|
||||
|
||||
Format: {username}:{password}
|
||||
Rules:
|
||||
Only one Credential must be in this text contents.
|
||||
All whitespace before and after all text will be removed.
|
||||
Any whitespace between text will be kept and used.
|
||||
The credential can be spanned across one or multiple lines as long as it
|
||||
abides with all the above rules and the format.
|
||||
|
||||
Example that follows the format and rules:
|
||||
`\tJohnd\noe@gm\n\rail.com\n:Pass1\n23\n\r \t \t`
|
||||
>>>Credential(username='Johndoe@gmail.com', password='Pass123')
|
||||
"""
|
||||
text = "".join([x.strip() for x in text.splitlines(keepends=False)]).strip()
|
||||
credential = re.fullmatch(r"^([^:]+?):([^:]+?)(?::(.+))?$", text)
|
||||
if credential:
|
||||
return cls(*credential.groups())
|
||||
raise ValueError("No credentials found in text string. Expecting the format `username:password`")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Credential:
|
||||
"""
|
||||
Load Credential from a file path.
|
||||
Use Credential.loads() for loading from text content and seeing the rules and
|
||||
format expected to be found in the URIs contents.
|
||||
"""
|
||||
return cls.loads(path.read_text("utf8"))
|
||||
6
unshackle/core/downloaders/__init__.py
Normal file
6
unshackle/core/downloaders/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .aria2c import aria2c
|
||||
from .curl_impersonate import curl_impersonate
|
||||
from .n_m3u8dl_re import n_m3u8dl_re
|
||||
from .requests import requests
|
||||
|
||||
__all__ = ("aria2c", "curl_impersonate", "requests", "n_m3u8dl_re")
|
||||
331
unshackle/core/downloaders/aria2c.py
Normal file
331
unshackle/core/downloaders/aria2c.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
from functools import partial
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Generator, MutableMapping, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from Crypto.Random import get_random_bytes
|
||||
from requests import Session
|
||||
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
||||
from rich import filesize
|
||||
from rich.text import Text
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
from unshackle.core.utilities import get_extension, get_free_port
|
||||
|
||||
|
||||
def rpc(caller: Callable, secret: str, method: str, params: Optional[list[Any]] = None) -> Any:
|
||||
"""Make a call to Aria2's JSON-RPC API."""
|
||||
try:
|
||||
rpc_res = caller(
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": get_random_bytes(16).hex(),
|
||||
"method": method,
|
||||
"params": [f"token:{secret}", *(params or [])],
|
||||
}
|
||||
).json()
|
||||
if rpc_res.get("code"):
|
||||
# wrap to console width - padding - '[Aria2c]: '
|
||||
error_pretty = "\n ".join(
|
||||
textwrap.wrap(
|
||||
f"RPC Error: {rpc_res['message']} ({rpc_res['code']})".strip(),
|
||||
width=console.width - 20,
|
||||
initial_indent="",
|
||||
)
|
||||
)
|
||||
console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty))
|
||||
return rpc_res["result"]
|
||||
except requests.exceptions.ConnectionError:
|
||||
# absorb, process likely ended as it was calling RPC
|
||||
return
|
||||
|
||||
|
||||
def download(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
if not urls:
|
||||
raise ValueError("urls must be provided and not empty")
|
||||
elif not isinstance(urls, (str, dict, list)):
|
||||
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
||||
|
||||
if not output_dir:
|
||||
raise ValueError("output_dir must be provided")
|
||||
elif not isinstance(output_dir, Path):
|
||||
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
||||
|
||||
if not filename:
|
||||
raise ValueError("filename must be provided")
|
||||
elif not isinstance(filename, str):
|
||||
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
||||
|
||||
if not isinstance(headers, (MutableMapping, type(None))):
|
||||
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
||||
|
||||
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||||
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
||||
|
||||
if not isinstance(proxy, (str, type(None))):
|
||||
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
||||
|
||||
if not max_workers:
|
||||
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||||
elif not isinstance(max_workers, int):
|
||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
if not binaries.Aria2:
|
||||
raise EnvironmentError("Aria2c executable not found...")
|
||||
|
||||
if proxy and not proxy.lower().startswith("http://"):
|
||||
raise ValueError("Only HTTP proxies are supported by aria2(c)")
|
||||
|
||||
if cookies and not isinstance(cookies, CookieJar):
|
||||
cookies = cookiejar_from_dict(cookies)
|
||||
|
||||
url_files = []
|
||||
for i, url in enumerate(urls):
|
||||
if isinstance(url, str):
|
||||
url_data = {"url": url}
|
||||
else:
|
||||
url_data: dict[str, Any] = url
|
||||
url_filename = filename.format(i=i, ext=get_extension(url_data["url"]))
|
||||
url_text = url_data["url"]
|
||||
url_text += f"\n\tdir={output_dir}"
|
||||
url_text += f"\n\tout={url_filename}"
|
||||
if cookies:
|
||||
mock_request = requests.Request(url=url_data["url"])
|
||||
cookie_header = get_cookie_header(cookies, mock_request)
|
||||
if cookie_header:
|
||||
url_text += f"\n\theader=Cookie: {cookie_header}"
|
||||
for key, value in url_data.items():
|
||||
if key == "url":
|
||||
continue
|
||||
if key == "headers":
|
||||
for header_name, header_value in value.items():
|
||||
url_text += f"\n\theader={header_name}: {header_value}"
|
||||
else:
|
||||
url_text += f"\n\t{key}={value}"
|
||||
url_files.append(url_text)
|
||||
url_file = "\n".join(url_files)
|
||||
|
||||
rpc_port = get_free_port()
|
||||
rpc_secret = get_random_bytes(16).hex()
|
||||
rpc_uri = f"http://127.0.0.1:{rpc_port}/jsonrpc"
|
||||
rpc_session = Session()
|
||||
|
||||
max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers))
|
||||
max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
|
||||
split = int(config.aria2c.get("split", 5))
|
||||
file_allocation = config.aria2c.get("file_allocation", "prealloc")
|
||||
if len(urls) > 1:
|
||||
split = 1
|
||||
file_allocation = "none"
|
||||
|
||||
arguments = [
|
||||
# [Basic Options]
|
||||
"--input-file",
|
||||
"-",
|
||||
"--all-proxy",
|
||||
proxy or "",
|
||||
"--continue=true",
|
||||
# [Connection Options]
|
||||
f"--max-concurrent-downloads={max_concurrent_downloads}",
|
||||
f"--max-connection-per-server={max_connection_per_server}",
|
||||
f"--split={split}", # each split uses their own connection
|
||||
"--max-file-not-found=5", # counted towards --max-tries
|
||||
"--max-tries=5",
|
||||
"--retry-wait=2",
|
||||
# [Advanced Options]
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
"--console-log-level=warn",
|
||||
"--download-result=default",
|
||||
f"--file-allocation={file_allocation}",
|
||||
"--summary-interval=0",
|
||||
# [RPC Options]
|
||||
"--enable-rpc=true",
|
||||
f"--rpc-listen-port={rpc_port}",
|
||||
f"--rpc-secret={rpc_secret}",
|
||||
]
|
||||
|
||||
for header, value in (headers or {}).items():
|
||||
if header.lower() == "cookie":
|
||||
raise ValueError("You cannot set Cookies as a header manually, please use the `cookies` param.")
|
||||
if header.lower() == "accept-encoding":
|
||||
# we cannot set an allowed encoding, or it will return compressed
|
||||
# and the code is not set up to uncompress the data
|
||||
continue
|
||||
if header.lower() == "referer":
|
||||
arguments.extend(["--referer", value])
|
||||
continue
|
||||
if header.lower() == "user-agent":
|
||||
arguments.extend(["--user-agent", value])
|
||||
continue
|
||||
arguments.extend(["--header", f"{header}: {value}"])
|
||||
|
||||
yield dict(total=len(urls))
|
||||
|
||||
try:
|
||||
p = subprocess.Popen([binaries.Aria2, *arguments], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL)
|
||||
|
||||
p.stdin.write(url_file.encode())
|
||||
p.stdin.close()
|
||||
|
||||
while p.poll() is None:
|
||||
global_stats: dict[str, Any] = (
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.getGlobalStat")
|
||||
or {}
|
||||
)
|
||||
|
||||
number_stopped = int(global_stats.get("numStoppedTotal", 0))
|
||||
download_speed = int(global_stats.get("downloadSpeed", -1))
|
||||
|
||||
if number_stopped:
|
||||
yield dict(completed=number_stopped)
|
||||
if download_speed != -1:
|
||||
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||||
|
||||
stopped_downloads: list[dict[str, Any]] = (
|
||||
rpc(
|
||||
caller=partial(rpc_session.post, url=rpc_uri),
|
||||
secret=rpc_secret,
|
||||
method="aria2.tellStopped",
|
||||
params=[0, 999999],
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
for dl in stopped_downloads:
|
||||
if dl["status"] == "error":
|
||||
used_uri = next(
|
||||
uri["uri"]
|
||||
for file in dl["files"]
|
||||
if file["selected"] == "true"
|
||||
for uri in file["uris"]
|
||||
if uri["status"] == "used"
|
||||
)
|
||||
error = f"Download Error (#{dl['gid']}): {dl['errorMessage']} ({dl['errorCode']}), {used_uri}"
|
||||
error_pretty = "\n ".join(
|
||||
textwrap.wrap(error, width=console.width - 20, initial_indent="")
|
||||
)
|
||||
console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty))
|
||||
raise ValueError(error)
|
||||
|
||||
if number_stopped == len(urls):
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown")
|
||||
break
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0:
|
||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
||||
except ConnectionResetError:
|
||||
# interrupted while passing URI to download
|
||||
raise KeyboardInterrupt()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode in (7, 0xC000013A):
|
||||
# 7 is when Aria2(c) handled the CTRL+C
|
||||
# 0xC000013A is when it never got the chance to
|
||||
raise KeyboardInterrupt()
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
raise
|
||||
except Exception:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[red]FAILED")
|
||||
raise
|
||||
finally:
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown")
|
||||
|
||||
|
||||
def aria2c(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download files using Aria2(c).
|
||||
https://aria2.github.io
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 100} (100% download total)
|
||||
- {completed: 1} (1% download progress out of 100%)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function.
|
||||
|
||||
Parameters:
|
||||
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
||||
"url" for the URI, and other keys for extra arguments to use per-URL.
|
||||
output_dir: The folder to save the file into. If the save path's directory does
|
||||
not exist then it will be made automatically.
|
||||
filename: The filename or filename template to use for each file. The variables
|
||||
you can use are `i` for the URL index and `ext` for the URL extension.
|
||||
headers: A mapping of HTTP Header Key/Values to use for all downloads.
|
||||
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for all downloads.
|
||||
proxy: An optional proxy URI to route connections through for all downloads.
|
||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||
min(32,(cpu_count+4)). Use for the --max-concurrent-downloads option.
|
||||
"""
|
||||
if proxy and not proxy.lower().startswith("http://"):
|
||||
# Only HTTP proxies are supported by aria2(c)
|
||||
proxy = urlparse(proxy)
|
||||
|
||||
port = get_free_port()
|
||||
username, password = get_random_bytes(8).hex(), get_random_bytes(8).hex()
|
||||
local_proxy = f"http://{username}:{password}@localhost:{port}"
|
||||
|
||||
scheme = {"https": "http+ssl", "socks5h": "socks"}.get(proxy.scheme, proxy.scheme)
|
||||
|
||||
remote_server = f"{scheme}://{proxy.hostname}"
|
||||
if proxy.port:
|
||||
remote_server += f":{proxy.port}"
|
||||
if proxy.username or proxy.password:
|
||||
remote_server += "#"
|
||||
if proxy.username:
|
||||
remote_server += proxy.username
|
||||
if proxy.password:
|
||||
remote_server += f":{proxy.password}"
|
||||
|
||||
p = subprocess.Popen(
|
||||
["pproxy", "-l", f"http://:{port}#{username}:{password}", "-r", remote_server],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
try:
|
||||
yield from download(urls, output_dir, filename, headers, cookies, local_proxy, max_workers)
|
||||
finally:
|
||||
p.kill()
|
||||
p.wait()
|
||||
return
|
||||
yield from download(urls, output_dir, filename, headers, cookies, proxy, max_workers)
|
||||
|
||||
|
||||
__all__ = ("aria2c",)
|
||||
259
unshackle/core/downloaders/curl_impersonate.py
Normal file
259
unshackle/core/downloaders/curl_impersonate.py
Normal file
@@ -0,0 +1,259 @@
|
||||
import math
|
||||
import time
|
||||
from concurrent import futures
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
||||
|
||||
from curl_cffi.requests import Session
|
||||
from rich import filesize
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
from unshackle.core.utilities import get_extension
|
||||
|
||||
MAX_ATTEMPTS = 5
|
||||
RETRY_WAIT = 2
|
||||
CHUNK_SIZE = 1024
|
||||
PROGRESS_WINDOW = 5
|
||||
BROWSER = config.curl_impersonate.get("browser", "chrome124")
|
||||
|
||||
|
||||
def download(url: str, save_path: Path, session: Session, **kwargs: Any) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download files using Curl Impersonate.
|
||||
https://github.com/lwthiker/curl-impersonate
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 123} (there are 123 chunks to download)
|
||||
- {total: None} (there are an unknown number of chunks to download)
|
||||
- {advance: 1} (one chunk was downloaded)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function. The
|
||||
`downloaded` key is custom and is not natively accepted by all rich progress bars.
|
||||
|
||||
Parameters:
|
||||
url: Web URL of a file to download.
|
||||
save_path: The path to save the file to. If the save path's directory does not
|
||||
exist then it will be made automatically.
|
||||
session: The Requests or Curl-Impersonate Session to make HTTP requests with.
|
||||
Useful to set Header, Cookie, and Proxy data. Connections are saved and
|
||||
re-used with the session so long as the server keeps the connection alive.
|
||||
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,
|
||||
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
|
||||
"""
|
||||
save_dir = save_path.parent
|
||||
control_file = save_path.with_name(f"{save_path.name}.!dev")
|
||||
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if control_file.exists():
|
||||
# consider the file corrupt if the control file exists
|
||||
save_path.unlink(missing_ok=True)
|
||||
control_file.unlink()
|
||||
elif save_path.exists():
|
||||
# if it exists, and no control file, then it should be safe
|
||||
yield dict(file_downloaded=save_path, written=save_path.stat().st_size)
|
||||
|
||||
# TODO: Design a control file format so we know how much of the file is missing
|
||||
control_file.write_bytes(b"")
|
||||
|
||||
attempts = 1
|
||||
try:
|
||||
while True:
|
||||
written = 0
|
||||
download_sizes = []
|
||||
last_speed_refresh = time.time()
|
||||
|
||||
try:
|
||||
stream = session.get(url, stream=True, **kwargs)
|
||||
stream.raise_for_status()
|
||||
|
||||
try:
|
||||
content_length = int(stream.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
|
||||
if content_length > 0:
|
||||
yield dict(total=math.ceil(content_length / CHUNK_SIZE))
|
||||
else:
|
||||
# we have no data to calculate total chunks
|
||||
yield dict(total=None) # indeterminate mode
|
||||
|
||||
with open(save_path, "wb") as f:
|
||||
for chunk in stream.iter_content(chunk_size=CHUNK_SIZE):
|
||||
download_size = len(chunk)
|
||||
f.write(chunk)
|
||||
written += download_size
|
||||
|
||||
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()
|
||||
|
||||
if content_length and written < content_length:
|
||||
raise IOError(f"Failed to read {content_length} bytes from the track URI.")
|
||||
|
||||
yield dict(file_downloaded=save_path, written=written)
|
||||
break
|
||||
except Exception as e:
|
||||
save_path.unlink(missing_ok=True)
|
||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||
raise e
|
||||
time.sleep(RETRY_WAIT)
|
||||
attempts += 1
|
||||
finally:
|
||||
control_file.unlink()
|
||||
|
||||
|
||||
def curl_impersonate(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download files using Curl Impersonate.
|
||||
https://github.com/lwthiker/curl-impersonate
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 123} (there are 123 chunks to download)
|
||||
- {total: None} (there are an unknown number of chunks to download)
|
||||
- {advance: 1} (one chunk was downloaded)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function.
|
||||
However, The `downloaded`, `file_downloaded` and `written` keys are custom and not
|
||||
natively accepted by rich progress bars.
|
||||
|
||||
Parameters:
|
||||
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
||||
"url" for the URI, and other keys for extra arguments to use per-URL.
|
||||
output_dir: The folder to save the file into. If the save path's directory does
|
||||
not exist then it will be made automatically.
|
||||
filename: The filename or filename template to use for each file. The variables
|
||||
you can use are `i` for the URL index and `ext` for the URL extension.
|
||||
headers: A mapping of HTTP Header Key/Values to use for all downloads.
|
||||
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for all downloads.
|
||||
proxy: An optional proxy URI to route connections through for all downloads.
|
||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||
min(32,(cpu_count+4)).
|
||||
"""
|
||||
if not urls:
|
||||
raise ValueError("urls must be provided and not empty")
|
||||
elif not isinstance(urls, (str, dict, list)):
|
||||
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
||||
|
||||
if not output_dir:
|
||||
raise ValueError("output_dir must be provided")
|
||||
elif not isinstance(output_dir, Path):
|
||||
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
||||
|
||||
if not filename:
|
||||
raise ValueError("filename must be provided")
|
||||
elif not isinstance(filename, str):
|
||||
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
||||
|
||||
if not isinstance(headers, (MutableMapping, type(None))):
|
||||
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
||||
|
||||
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||||
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
||||
|
||||
if not isinstance(proxy, (str, type(None))):
|
||||
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
||||
|
||||
if not isinstance(max_workers, (int, type(None))):
|
||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
urls = [
|
||||
dict(save_path=save_path, **url) if isinstance(url, dict) else dict(url=url, save_path=save_path)
|
||||
for i, url in enumerate(urls)
|
||||
for save_path in [
|
||||
output_dir / filename.format(i=i, ext=get_extension(url["url"] if isinstance(url, dict) else url))
|
||||
]
|
||||
]
|
||||
|
||||
session = Session(impersonate=BROWSER)
|
||||
if headers:
|
||||
headers = {k: v for k, v in headers.items() if k.lower() != "accept-encoding"}
|
||||
session.headers.update(headers)
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
if proxy:
|
||||
session.proxies.update({"all": proxy})
|
||||
|
||||
yield dict(total=len(urls))
|
||||
|
||||
download_sizes = []
|
||||
last_speed_refresh = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
for i, future in enumerate(
|
||||
futures.as_completed((pool.submit(download, session=session, **url) for url in urls))
|
||||
):
|
||||
file_path, download_size = None, None
|
||||
try:
|
||||
for status_update in future.result():
|
||||
if status_update.get("file_downloaded") and status_update.get("written"):
|
||||
file_path = status_update["file_downloaded"]
|
||||
download_size = status_update["written"]
|
||||
elif len(urls) == 1:
|
||||
# these are per-chunk updates, only useful if it's one big file
|
||||
yield status_update
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[yellow]CANCELLING")
|
||||
pool.shutdown(wait=True, cancel_futures=True)
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
# tell dl that it was cancelled
|
||||
# the pool is already shut down, so exiting loop is fine
|
||||
raise
|
||||
except Exception:
|
||||
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)
|
||||
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__ = ("curl_impersonate",)
|
||||
299
unshackle/core/downloaders/n_m3u8dl_re.py
Normal file
299
unshackle/core/downloaders/n_m3u8dl_re.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import warnings
|
||||
from http.cookiejar import CookieJar
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
||||
|
||||
import requests
|
||||
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
|
||||
# Ignore FutureWarnings
|
||||
warnings.simplefilter(action="ignore", category=FutureWarning)
|
||||
|
||||
AUDIO_CODEC_MAP = {"AAC": "mp4a", "AC3": "ac-3", "EC3": "ec-3"}
|
||||
VIDEO_CODEC_MAP = {"AVC": "avc", "HEVC": "hvc", "DV": "dvh", "HLG": "hev"}
|
||||
|
||||
|
||||
def track_selection(track: object) -> list[str]:
|
||||
"""Return the N_m3u8DL-RE stream selection arguments for a track."""
|
||||
|
||||
if "dash" in track.data:
|
||||
adaptation_set = track.data["dash"]["adaptation_set"]
|
||||
representation = track.data["dash"]["representation"]
|
||||
|
||||
track_type = track.__class__.__name__
|
||||
codec = track.codec.name
|
||||
bitrate = track.bitrate // 1000
|
||||
language = track.language
|
||||
width = track.width if track_type == "Video" else None
|
||||
height = track.height if track_type == "Video" else None
|
||||
range = track.range.name if track_type == "Video" else None
|
||||
|
||||
elif "ism" in track.data:
|
||||
stream_index = track.data["ism"]["stream_index"]
|
||||
quality_level = track.data["ism"]["quality_level"]
|
||||
|
||||
track_type = track.__class__.__name__
|
||||
codec = track.codec.name
|
||||
bitrate = track.bitrate // 1000
|
||||
language = track.language
|
||||
width = track.width if track_type == "Video" else None
|
||||
height = track.height if track_type == "Video" else None
|
||||
range = track.range.name if track_type == "Video" else None
|
||||
adaptation_set = stream_index
|
||||
representation = quality_level
|
||||
|
||||
else:
|
||||
return []
|
||||
|
||||
if track_type == "Audio":
|
||||
codecs = AUDIO_CODEC_MAP.get(codec)
|
||||
langs = adaptation_set.findall("lang") + representation.findall("lang")
|
||||
track_ids = list(
|
||||
set(
|
||||
v
|
||||
for x in chain(adaptation_set, representation)
|
||||
for v in (x.get("audioTrackId"), x.get("id"))
|
||||
if v is not None
|
||||
)
|
||||
)
|
||||
roles = adaptation_set.findall("Role") + representation.findall("Role")
|
||||
role = ":role=main" if next((i for i in roles if i.get("value").lower() == "main"), None) else ""
|
||||
bandwidth = f"bwMin={bitrate}:bwMax={bitrate + 5}"
|
||||
|
||||
if langs:
|
||||
track_selection = ["-sa", f"lang={language}:codecs={codecs}:{bandwidth}{role}"]
|
||||
elif len(track_ids) == 1:
|
||||
track_selection = ["-sa", f"id={track_ids[0]}"]
|
||||
else:
|
||||
track_selection = ["-sa", f"for=best{role}"]
|
||||
return track_selection
|
||||
|
||||
if track_type == "Video":
|
||||
# adjust codec based on range
|
||||
codec_adjustments = {("HEVC", "DV"): "DV", ("HEVC", "HLG"): "HLG"}
|
||||
codec = codec_adjustments.get((codec, range), codec)
|
||||
codecs = VIDEO_CODEC_MAP.get(codec)
|
||||
|
||||
bandwidth = f"bwMin={bitrate}:bwMax={bitrate + 5}"
|
||||
if width and height:
|
||||
resolution = f"{width}x{height}"
|
||||
elif width:
|
||||
resolution = f"{width}*"
|
||||
else:
|
||||
resolution = "for=best"
|
||||
if resolution.startswith("for="):
|
||||
track_selection = ["-sv", resolution]
|
||||
track_selection.append(f"codecs={codecs}:{bandwidth}")
|
||||
else:
|
||||
track_selection = ["-sv", f"res={resolution}:codecs={codecs}:{bandwidth}"]
|
||||
return track_selection
|
||||
|
||||
|
||||
def download(
|
||||
urls: Union[str, dict[str, Any], list[str], list[dict[str, Any]]],
|
||||
track: object,
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
content_keys: Optional[dict[str, Any]] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
if not urls:
|
||||
raise ValueError("urls must be provided and not empty")
|
||||
elif not isinstance(urls, (str, dict, list)):
|
||||
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
||||
|
||||
if not output_dir:
|
||||
raise ValueError("output_dir must be provided")
|
||||
elif not isinstance(output_dir, Path):
|
||||
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
||||
|
||||
if not filename:
|
||||
raise ValueError("filename must be provided")
|
||||
elif not isinstance(filename, str):
|
||||
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
||||
|
||||
if not isinstance(headers, (MutableMapping, type(None))):
|
||||
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
||||
|
||||
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||||
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
||||
|
||||
if not isinstance(proxy, (str, type(None))):
|
||||
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
||||
|
||||
if not max_workers:
|
||||
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||||
elif not isinstance(max_workers, int):
|
||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
if not binaries.N_m3u8DL_RE:
|
||||
raise EnvironmentError("N_m3u8DL-RE executable not found...")
|
||||
|
||||
if cookies and not isinstance(cookies, CookieJar):
|
||||
cookies = cookiejar_from_dict(cookies)
|
||||
|
||||
track_type = track.__class__.__name__
|
||||
thread_count = str(config.n_m3u8dl_re.get("thread_count", max_workers))
|
||||
ad_keyword = config.n_m3u8dl_re.get("ad_keyword")
|
||||
|
||||
arguments = [
|
||||
track.url,
|
||||
"--save-dir",
|
||||
output_dir,
|
||||
"--tmp-dir",
|
||||
output_dir,
|
||||
"--thread-count",
|
||||
thread_count,
|
||||
"--no-log",
|
||||
"--write-meta-json",
|
||||
"false",
|
||||
]
|
||||
|
||||
for header, value in (headers or {}).items():
|
||||
if header.lower() in ("accept-encoding", "cookie"):
|
||||
continue
|
||||
arguments.extend(["--header", f"{header}: {value}"])
|
||||
|
||||
if cookies:
|
||||
cookie_header = get_cookie_header(cookies, requests.Request(url=track.url))
|
||||
if cookie_header:
|
||||
arguments.extend(["--header", f"Cookie: {cookie_header}"])
|
||||
|
||||
if proxy:
|
||||
arguments.extend(["--custom-proxy", proxy])
|
||||
|
||||
if content_keys:
|
||||
for kid, key in content_keys.items():
|
||||
keys = f"{kid.hex}:{key.lower()}"
|
||||
arguments.extend(["--key", keys])
|
||||
arguments.extend(["--use-shaka-packager"])
|
||||
|
||||
if ad_keyword:
|
||||
arguments.extend(["--ad-keyword", ad_keyword])
|
||||
|
||||
if track.descriptor.name == "URL":
|
||||
error = f"[N_m3u8DL-RE]: {track.descriptor} is currently not supported"
|
||||
raise ValueError(error)
|
||||
elif track.descriptor.name == "DASH":
|
||||
arguments.extend(track_selection(track))
|
||||
|
||||
# TODO: improve this nonsense
|
||||
percent_re = re.compile(r"(\d+\.\d+%)")
|
||||
speed_re = re.compile(r"(?<!/)(\d+\.\d+MB)(?!.*\/)")
|
||||
warn = re.compile(r"(WARN : Response.*)")
|
||||
error = re.compile(r"(ERROR.*)")
|
||||
size_patterns = [
|
||||
re.compile(r"(\d+\.\d+MB/\d+\.\d+GB)"),
|
||||
re.compile(r"(\d+\.\d+GB/\d+\.\d+GB)"),
|
||||
re.compile(r"(\d+\.\d+MB/\d+\.\d+MB)"),
|
||||
]
|
||||
|
||||
yield dict(total=100)
|
||||
|
||||
try:
|
||||
with subprocess.Popen(
|
||||
[binaries.N_m3u8DL_RE, *arguments], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
||||
) as p:
|
||||
for line in p.stdout:
|
||||
output = line.strip()
|
||||
if output:
|
||||
percent = percent_re.search(output)
|
||||
speed = speed_re.search(output)
|
||||
size = next(
|
||||
(pattern.search(output).group(1) for pattern in size_patterns if pattern.search(output)), ""
|
||||
)
|
||||
|
||||
if speed:
|
||||
yield dict(downloaded=f"{speed.group(1)}ps {size}")
|
||||
if percent:
|
||||
progress = int(percent.group(1).split(".")[0])
|
||||
yield dict(completed=progress) if progress < 100 else dict(downloaded="Merging")
|
||||
|
||||
if warn.search(output):
|
||||
console.log(f"{track_type} " + warn.search(output).group(1))
|
||||
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0:
|
||||
if error.search(output):
|
||||
raise ValueError(f"[N_m3u8DL-RE]: {error.search(output).group(1)}")
|
||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
||||
|
||||
except ConnectionResetError:
|
||||
# interrupted while passing URI to download
|
||||
raise KeyboardInterrupt()
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
raise
|
||||
except Exception:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[red]FAILED")
|
||||
raise
|
||||
|
||||
|
||||
def n_m3u8dl_re(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
track: object,
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
content_keys: Optional[dict[str, Any]] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download files using N_m3u8DL-RE.
|
||||
https://github.com/nilaoda/N_m3u8DL-RE
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 100} (100% download total)
|
||||
- {completed: 1} (1% download progress out of 100%)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function.
|
||||
|
||||
Parameters:
|
||||
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
||||
"url" for the URI, and other keys for extra arguments to use per-URL.
|
||||
track: The track to download. Used to get track attributes for the selection
|
||||
process. Note that Track.Descriptor.URL is not supported by N_m3u8DL-RE.
|
||||
output_dir: The folder to save the file into. If the save path's directory does
|
||||
not exist then it will be made automatically.
|
||||
filename: The filename or filename template to use for each file. The variables
|
||||
you can use are `i` for the URL index and `ext` for the URL extension.
|
||||
headers: A mapping of HTTP Header Key/Values to use for the download.
|
||||
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for the download.
|
||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||
min(32,(cpu_count+4)). Can be set in config with --thread-count option.
|
||||
content_keys: The content keys to use for decryption.
|
||||
"""
|
||||
track_type = track.__class__.__name__
|
||||
|
||||
log = logging.getLogger("N_m3u8DL-RE")
|
||||
if proxy and not config.n_m3u8dl_re.get("use_proxy", True):
|
||||
log.warning(f"{track_type}: Ignoring proxy as N_m3u8DL-RE is set to use_proxy=False")
|
||||
proxy = None
|
||||
|
||||
yield from download(urls, track, output_dir, filename, headers, cookies, proxy, max_workers, content_keys)
|
||||
|
||||
|
||||
__all__ = ("n_m3u8dl_re",)
|
||||
266
unshackle/core/downloaders/requests.py
Normal file
266
unshackle/core/downloaders/requests.py
Normal file
@@ -0,0 +1,266 @@
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import as_completed
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
||||
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from rich import filesize
|
||||
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
from unshackle.core.utilities import get_extension
|
||||
|
||||
MAX_ATTEMPTS = 5
|
||||
RETRY_WAIT = 2
|
||||
CHUNK_SIZE = 1024
|
||||
PROGRESS_WINDOW = 5
|
||||
|
||||
DOWNLOAD_SIZES = []
|
||||
LAST_SPEED_REFRESH = time.time()
|
||||
|
||||
|
||||
def download(
|
||||
url: str, save_path: Path, session: Optional[Session] = None, segmented: bool = False, **kwargs: Any
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download a file using Python Requests.
|
||||
https://requests.readthedocs.io
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 123} (there are 123 chunks to download)
|
||||
- {total: None} (there are an unknown number of chunks to download)
|
||||
- {advance: 1} (one chunk was downloaded)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function. The
|
||||
`downloaded` key is custom and is not natively accepted by all rich progress bars.
|
||||
|
||||
Parameters:
|
||||
url: Web URL of a file to download.
|
||||
save_path: The path to save the file to. If the save path's directory does not
|
||||
exist then it will be made automatically.
|
||||
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
|
||||
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
|
||||
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"}`.
|
||||
"""
|
||||
global LAST_SPEED_REFRESH
|
||||
|
||||
session = session or Session()
|
||||
|
||||
save_dir = save_path.parent
|
||||
control_file = save_path.with_name(f"{save_path.name}.!dev")
|
||||
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if control_file.exists():
|
||||
# consider the file corrupt if the control file exists
|
||||
save_path.unlink(missing_ok=True)
|
||||
control_file.unlink()
|
||||
elif save_path.exists():
|
||||
# if it exists, and no control file, then it should be safe
|
||||
yield dict(file_downloaded=save_path, 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
|
||||
control_file.write_bytes(b"")
|
||||
|
||||
attempts = 1
|
||||
try:
|
||||
while True:
|
||||
written = 0
|
||||
|
||||
# these are for single-url speed calcs only
|
||||
download_sizes = []
|
||||
last_speed_refresh = time.time()
|
||||
|
||||
try:
|
||||
stream = session.get(url, stream=True, **kwargs)
|
||||
stream.raise_for_status()
|
||||
|
||||
if not segmented:
|
||||
try:
|
||||
content_length = int(stream.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
|
||||
if content_length > 0:
|
||||
yield dict(total=math.ceil(content_length / CHUNK_SIZE))
|
||||
else:
|
||||
# we have no data to calculate total chunks
|
||||
yield dict(total=None) # indeterminate mode
|
||||
|
||||
with open(save_path, "wb") as f:
|
||||
for chunk in stream.iter_content(chunk_size=CHUNK_SIZE):
|
||||
download_size = len(chunk)
|
||||
f.write(chunk)
|
||||
written += download_size
|
||||
|
||||
if not segmented:
|
||||
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()
|
||||
|
||||
if content_length and written < content_length:
|
||||
raise IOError(f"Failed to read {content_length} bytes from the track URI.")
|
||||
|
||||
yield dict(file_downloaded=save_path, written=written)
|
||||
|
||||
if segmented:
|
||||
yield dict(advance=1)
|
||||
now = time.time()
|
||||
time_since = now - LAST_SPEED_REFRESH
|
||||
if written: # no size == skipped dl
|
||||
DOWNLOAD_SIZES.append(written)
|
||||
if DOWNLOAD_SIZES and time_since > PROGRESS_WINDOW:
|
||||
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()
|
||||
break
|
||||
except Exception as e:
|
||||
save_path.unlink(missing_ok=True)
|
||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||
raise e
|
||||
time.sleep(RETRY_WAIT)
|
||||
attempts += 1
|
||||
finally:
|
||||
control_file.unlink()
|
||||
|
||||
|
||||
def requests(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
output_dir: Path,
|
||||
filename: str,
|
||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Download a file using Python Requests.
|
||||
https://requests.readthedocs.io
|
||||
|
||||
Yields the following download status updates while chunks are downloading:
|
||||
|
||||
- {total: 123} (there are 123 chunks to download)
|
||||
- {total: None} (there are an unknown number of chunks to download)
|
||||
- {advance: 1} (one chunk was downloaded)
|
||||
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||||
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||||
|
||||
The data is in the same format accepted by rich's progress.update() function.
|
||||
However, The `downloaded`, `file_downloaded` and `written` keys are custom and not
|
||||
natively accepted by rich progress bars.
|
||||
|
||||
Parameters:
|
||||
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
||||
"url" for the URI, and other keys for extra arguments to use per-URL.
|
||||
output_dir: The folder to save the file into. If the save path's directory does
|
||||
not exist then it will be made automatically.
|
||||
filename: The filename or filename template to use for each file. The variables
|
||||
you can use are `i` for the URL index and `ext` for the URL extension.
|
||||
headers: A mapping of HTTP Header Key/Values to use for all downloads.
|
||||
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for all downloads.
|
||||
proxy: An optional proxy URI to route connections through for all downloads.
|
||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||
min(32,(cpu_count+4)).
|
||||
"""
|
||||
if not urls:
|
||||
raise ValueError("urls must be provided and not empty")
|
||||
elif not isinstance(urls, (str, dict, list)):
|
||||
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
||||
|
||||
if not output_dir:
|
||||
raise ValueError("output_dir must be provided")
|
||||
elif not isinstance(output_dir, Path):
|
||||
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
||||
|
||||
if not filename:
|
||||
raise ValueError("filename must be provided")
|
||||
elif not isinstance(filename, str):
|
||||
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
||||
|
||||
if not isinstance(headers, (MutableMapping, type(None))):
|
||||
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
||||
|
||||
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||||
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
||||
|
||||
if not isinstance(proxy, (str, type(None))):
|
||||
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
||||
|
||||
if not isinstance(max_workers, (int, type(None))):
|
||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
if not max_workers:
|
||||
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||||
|
||||
urls = [
|
||||
dict(save_path=save_path, **url) if isinstance(url, dict) else dict(url=url, save_path=save_path)
|
||||
for i, url in enumerate(urls)
|
||||
for save_path in [
|
||||
output_dir / filename.format(i=i, ext=get_extension(url["url"] if isinstance(url, dict) else url))
|
||||
]
|
||||
]
|
||||
|
||||
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:
|
||||
headers = {k: v for k, v in headers.items() if k.lower() != "accept-encoding"}
|
||||
session.headers.update(headers)
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
if proxy:
|
||||
session.proxies.update({"all": proxy})
|
||||
|
||||
yield dict(total=len(urls))
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
for future in as_completed(pool.submit(download, session=session, segmented=False, **url) for url in urls):
|
||||
try:
|
||||
yield from future.result()
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[yellow]CANCELLING")
|
||||
pool.shutdown(wait=True, cancel_futures=True)
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
# tell dl that it was cancelled
|
||||
# the pool is already shut down, so exiting loop is fine
|
||||
raise
|
||||
except Exception:
|
||||
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
|
||||
finally:
|
||||
DOWNLOAD_SIZES.clear()
|
||||
|
||||
|
||||
__all__ = ("requests",)
|
||||
10
unshackle/core/drm/__init__.py
Normal file
10
unshackle/core/drm/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Union
|
||||
|
||||
from unshackle.core.drm.clearkey import ClearKey
|
||||
from unshackle.core.drm.playready import PlayReady
|
||||
from unshackle.core.drm.widevine import Widevine
|
||||
|
||||
DRM_T = Union[ClearKey, Widevine, PlayReady]
|
||||
|
||||
|
||||
__all__ = ("ClearKey", "Widevine", "PlayReady", "DRM_T")
|
||||
111
unshackle/core/drm/clearkey.py
Normal file
111
unshackle/core/drm/clearkey.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Util.Padding import unpad
|
||||
from m3u8.model import Key
|
||||
from requests import Session
|
||||
|
||||
|
||||
class ClearKey:
|
||||
"""AES Clear Key DRM System."""
|
||||
|
||||
def __init__(self, key: Union[bytes, str], iv: Optional[Union[bytes, str]] = None):
|
||||
"""
|
||||
Generally IV should be provided where possible. If not provided, it will be
|
||||
set to \x00 of the same bit-size of the key.
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = bytes.fromhex(key.replace("0x", ""))
|
||||
if not isinstance(key, bytes):
|
||||
raise ValueError(f"Expected AES Key to be bytes, not {key!r}")
|
||||
if not iv:
|
||||
iv = b"\x00"
|
||||
if isinstance(iv, str):
|
||||
iv = bytes.fromhex(iv.replace("0x", ""))
|
||||
if not isinstance(iv, bytes):
|
||||
raise ValueError(f"Expected IV to be bytes, not {iv!r}")
|
||||
|
||||
if len(iv) < len(key):
|
||||
iv = iv * (len(key) - len(iv) + 1)
|
||||
|
||||
self.key: bytes = key
|
||||
self.iv: bytes = iv
|
||||
|
||||
def decrypt(self, path: Path) -> None:
|
||||
"""Decrypt a Track with AES Clear Key DRM."""
|
||||
if not path or not path.exists():
|
||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||
|
||||
decrypted = AES.new(self.key, AES.MODE_CBC, self.iv).decrypt(path.read_bytes())
|
||||
|
||||
try:
|
||||
decrypted = unpad(decrypted, AES.block_size)
|
||||
except ValueError:
|
||||
# the decrypted data is likely already in the block size boundary
|
||||
pass
|
||||
|
||||
decrypted_path = path.with_suffix(f".decrypted{path.suffix}")
|
||||
decrypted_path.write_bytes(decrypted)
|
||||
|
||||
path.unlink()
|
||||
shutil.move(decrypted_path, path)
|
||||
|
||||
@classmethod
|
||||
def from_m3u_key(cls, m3u_key: Key, session: Optional[Session] = 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):
|
||||
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"):
|
||||
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")
|
||||
if not m3u_key.uri:
|
||||
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:"):
|
||||
media_types, data = m3u_key.uri[5:].split(",")
|
||||
media_types = media_types.split(";")
|
||||
if "base64" in media_types:
|
||||
data = base64.b64decode(data)
|
||||
key = data
|
||||
else:
|
||||
url = urljoin(m3u_key.base_uri, m3u_key.uri)
|
||||
res = session.get(url)
|
||||
res.raise_for_status()
|
||||
if not res.content:
|
||||
raise EOFError("Unexpected Empty Response by M3U Key URI.")
|
||||
if len(res.content) < 16:
|
||||
raise EOFError(f"Unexpected Length of Key ({len(res.content)} bytes) in M3U Key.")
|
||||
key = res.content
|
||||
|
||||
if m3u_key.iv:
|
||||
iv = bytes.fromhex(m3u_key.iv.replace("0x", ""))
|
||||
else:
|
||||
iv = None
|
||||
|
||||
return cls(key=key, iv=iv)
|
||||
|
||||
|
||||
__all__ = ("ClearKey",)
|
||||
281
unshackle/core/drm/playready.py
Normal file
281
unshackle/core/drm/playready.py
Normal file
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import m3u8
|
||||
from construct import Container
|
||||
from pymp4.parser import Box
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.system.pssh import PSSH
|
||||
from requests import Session
|
||||
from rich.text import Text
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.utilities import get_boxes
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
|
||||
|
||||
class PlayReady:
|
||||
"""PlayReady DRM System."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pssh: PSSH,
|
||||
kid: Union[UUID, str, bytes, None] = None,
|
||||
pssh_b64: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if not pssh:
|
||||
raise ValueError("Provided PSSH is empty.")
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
kids: list[UUID] = []
|
||||
for header in pssh.wrm_headers:
|
||||
try:
|
||||
signed_ids, _, _, _ = header.read_attributes()
|
||||
except Exception:
|
||||
continue
|
||||
for signed_id in signed_ids:
|
||||
try:
|
||||
kids.append(UUID(bytes_le=base64.b64decode(signed_id.value)))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if kid:
|
||||
if isinstance(kid, str):
|
||||
kid = UUID(hex=kid)
|
||||
elif isinstance(kid, bytes):
|
||||
kid = UUID(bytes=kid)
|
||||
if not isinstance(kid, UUID):
|
||||
raise ValueError(f"Expected kid to be a {UUID}, str, or bytes, not {kid!r}")
|
||||
if kid not in kids:
|
||||
kids.append(kid)
|
||||
|
||||
self._pssh = pssh
|
||||
self._kids = kids
|
||||
|
||||
if not self.kids:
|
||||
raise PlayReady.Exceptions.KIDNotFound("No Key ID was found within PSSH and none were provided.")
|
||||
|
||||
self.content_keys: dict[UUID, str] = {}
|
||||
self.data: dict = kwargs or {}
|
||||
if pssh_b64:
|
||||
self.data.setdefault("pssh_b64", pssh_b64)
|
||||
|
||||
@classmethod
|
||||
def from_track(cls, track: AnyTrack, session: Optional[Session] = None) -> PlayReady:
|
||||
if not session:
|
||||
session = Session()
|
||||
session.headers.update(config.headers)
|
||||
|
||||
kid: Optional[UUID] = None
|
||||
pssh_boxes: list[Container] = []
|
||||
tenc_boxes: list[Container] = []
|
||||
|
||||
if track.descriptor == track.Descriptor.HLS:
|
||||
m3u_url = track.url
|
||||
master = m3u8.loads(session.get(m3u_url).text, uri=m3u_url)
|
||||
pssh_boxes.extend(
|
||||
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
|
||||
for x in (master.session_keys or master.keys)
|
||||
if x and x.keyformat and "playready" in x.keyformat.lower()
|
||||
)
|
||||
|
||||
init_data = track.get_init_segment(session=session)
|
||||
if init_data:
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
kid = UUID(bytes=base64.b64decode(enc_key_id))
|
||||
pssh_boxes.extend(list(get_boxes(init_data, b"pssh")))
|
||||
tenc_boxes.extend(list(get_boxes(init_data, b"tenc")))
|
||||
|
||||
pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID.bytes), None)
|
||||
if not pssh:
|
||||
raise PlayReady.Exceptions.PSSHNotFound("PSSH was not found in track data.")
|
||||
|
||||
tenc = next(iter(tenc_boxes), None)
|
||||
if not kid and tenc and tenc.key_ID.int != 0:
|
||||
kid = tenc.key_ID
|
||||
|
||||
pssh_bytes = Box.build(pssh)
|
||||
return cls(pssh=PSSH(pssh_bytes), kid=kid, pssh_b64=base64.b64encode(pssh_bytes).decode())
|
||||
|
||||
@classmethod
|
||||
def from_init_data(cls, init_data: bytes) -> PlayReady:
|
||||
if not init_data:
|
||||
raise ValueError("Init data should be provided.")
|
||||
if not isinstance(init_data, bytes):
|
||||
raise TypeError(f"Expected init data to be bytes, not {init_data!r}")
|
||||
|
||||
kid: Optional[UUID] = None
|
||||
pssh_boxes: list[Container] = list(get_boxes(init_data, b"pssh"))
|
||||
tenc_boxes: list[Container] = list(get_boxes(init_data, b"tenc"))
|
||||
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
kid = UUID(bytes=base64.b64decode(enc_key_id))
|
||||
|
||||
pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID.bytes), None)
|
||||
if not pssh:
|
||||
raise PlayReady.Exceptions.PSSHNotFound("PSSH was not found in track data.")
|
||||
|
||||
tenc = next(iter(tenc_boxes), None)
|
||||
if not kid and tenc and tenc.key_ID.int != 0:
|
||||
kid = tenc.key_ID
|
||||
|
||||
pssh_bytes = Box.build(pssh)
|
||||
return cls(pssh=PSSH(pssh_bytes), kid=kid, pssh_b64=base64.b64encode(pssh_bytes).decode())
|
||||
|
||||
@property
|
||||
def pssh(self) -> PSSH:
|
||||
return self._pssh
|
||||
|
||||
@property
|
||||
def pssh_b64(self) -> Optional[str]:
|
||||
return self.data.get("pssh_b64")
|
||||
|
||||
@property
|
||||
def kid(self) -> Optional[UUID]:
|
||||
return next(iter(self.kids), None)
|
||||
|
||||
@property
|
||||
def kids(self) -> list[UUID]:
|
||||
return self._kids
|
||||
|
||||
def get_content_keys(self, cdm: PlayReadyCdm, certificate: Callable, licence: Callable) -> None:
|
||||
for kid in self.kids:
|
||||
if kid in self.content_keys:
|
||||
continue
|
||||
session_id = cdm.open()
|
||||
try:
|
||||
challenge = cdm.get_license_challenge(session_id, self.pssh.wrm_headers[0])
|
||||
license_res = licence(challenge=challenge)
|
||||
|
||||
if isinstance(license_res, bytes):
|
||||
license_str = license_res.decode(errors="ignore")
|
||||
else:
|
||||
license_str = str(license_res)
|
||||
|
||||
if "<License>" not in license_str:
|
||||
try:
|
||||
license_str = base64.b64decode(license_str + "===").decode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cdm.parse_license(session_id, license_str)
|
||||
keys = {key.key_id: key.key.hex() for key in cdm.get_keys(session_id)}
|
||||
self.content_keys.update(keys)
|
||||
finally:
|
||||
cdm.close(session_id)
|
||||
|
||||
if not self.content_keys:
|
||||
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
|
||||
|
||||
def decrypt(self, path: Path) -> None:
|
||||
if not self.content_keys:
|
||||
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
||||
if not binaries.ShakaPackager:
|
||||
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
||||
if not path or not path.exists():
|
||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||
|
||||
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
arguments = [
|
||||
f"input={path},stream=0,output={output_path},output_format=MP4",
|
||||
"--enable_raw_key_decryption",
|
||||
"--keys",
|
||||
",".join(
|
||||
[
|
||||
*[
|
||||
f"label={i}:key_id={kid.hex}:key={key.lower()}"
|
||||
for i, (kid, key) in enumerate(self.content_keys.items())
|
||||
],
|
||||
*[
|
||||
f"label={i}:key_id={'00' * 16}:key={key.lower()}"
|
||||
for i, (kid, key) in enumerate(self.content_keys.items(), len(self.content_keys))
|
||||
],
|
||||
]
|
||||
),
|
||||
"--temp_dir",
|
||||
config.directories.temp,
|
||||
]
|
||||
|
||||
p = subprocess.Popen(
|
||||
[binaries.ShakaPackager, *arguments],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
stream_skipped = False
|
||||
had_error = False
|
||||
shaka_log_buffer = ""
|
||||
for line in iter(p.stderr.readline, ""):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "Skip stream" in line:
|
||||
stream_skipped = True
|
||||
if ":INFO:" in line:
|
||||
continue
|
||||
if "I0" in line or "W0" in line:
|
||||
continue
|
||||
if ":ERROR:" in line:
|
||||
had_error = True
|
||||
if "Insufficient bits in bitstream for given AVC profile" in line:
|
||||
continue
|
||||
shaka_log_buffer += f"{line.strip()}\n"
|
||||
|
||||
if shaka_log_buffer:
|
||||
shaka_log_buffer = "\n ".join(
|
||||
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
|
||||
)
|
||||
console.log(Text.from_ansi("\n[PlayReady]: " + shaka_log_buffer))
|
||||
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0 or had_error:
|
||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
||||
|
||||
path.unlink()
|
||||
if not stream_skipped:
|
||||
shutil.move(output_path, path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode == 0xC000013A:
|
||||
raise KeyboardInterrupt()
|
||||
raise
|
||||
|
||||
class Exceptions:
|
||||
class PSSHNotFound(Exception):
|
||||
pass
|
||||
|
||||
class KIDNotFound(Exception):
|
||||
pass
|
||||
|
||||
class CEKNotFound(Exception):
|
||||
pass
|
||||
|
||||
class EmptyLicense(Exception):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ("PlayReady",)
|
||||
334
unshackle/core/drm/widevine.py
Normal file
334
unshackle/core/drm/widevine.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import m3u8
|
||||
from construct import Container
|
||||
from pymp4.parser import Box
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH
|
||||
from requests import Session
|
||||
from rich.text import Text
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.utilities import get_boxes
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
|
||||
|
||||
class Widevine:
|
||||
"""Widevine DRM System."""
|
||||
|
||||
def __init__(self, pssh: PSSH, kid: Union[UUID, str, bytes, None] = None, **kwargs: Any):
|
||||
if not pssh:
|
||||
raise ValueError("Provided PSSH is empty.")
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
if pssh.system_id == PSSH.SystemId.PlayReady:
|
||||
pssh.to_widevine()
|
||||
|
||||
if kid:
|
||||
if isinstance(kid, str):
|
||||
kid = UUID(hex=kid)
|
||||
elif isinstance(kid, bytes):
|
||||
kid = UUID(bytes=kid)
|
||||
if not isinstance(kid, UUID):
|
||||
raise ValueError(f"Expected kid to be a {UUID}, str, or bytes, not {kid!r}")
|
||||
pssh.set_key_ids([kid])
|
||||
|
||||
self._pssh = pssh
|
||||
|
||||
if not self.kids:
|
||||
raise Widevine.Exceptions.KIDNotFound("No Key ID was found within PSSH and none were provided.")
|
||||
|
||||
self.content_keys: dict[UUID, str] = {}
|
||||
self.data: dict = kwargs or {}
|
||||
|
||||
@classmethod
|
||||
def from_track(cls, track: AnyTrack, session: Optional[Session] = None) -> Widevine:
|
||||
"""
|
||||
Get PSSH and KID from within the Initiation Segment of the Track Data.
|
||||
It also tries to get PSSH and KID from other track data like M3U8 data
|
||||
as well as through ffprobe.
|
||||
|
||||
Create a Widevine DRM System object from a track's information.
|
||||
This should only be used if a PSSH could not be provided directly.
|
||||
It is *rare* to need to use this.
|
||||
|
||||
You may provide your own requests session to be able to use custom
|
||||
headers and more.
|
||||
|
||||
Raises:
|
||||
PSSHNotFound - If the PSSH was not found within the data.
|
||||
KIDNotFound - If the KID was not found within the data or PSSH.
|
||||
"""
|
||||
if not session:
|
||||
session = Session()
|
||||
session.headers.update(config.headers)
|
||||
|
||||
kid: Optional[UUID] = None
|
||||
pssh_boxes: list[Container] = []
|
||||
tenc_boxes: list[Container] = []
|
||||
|
||||
if track.descriptor == track.Descriptor.HLS:
|
||||
m3u_url = track.url
|
||||
master = m3u8.loads(session.get(m3u_url).text, uri=m3u_url)
|
||||
pssh_boxes.extend(
|
||||
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
|
||||
for x in (master.session_keys or master.keys)
|
||||
if x and x.keyformat and x.keyformat.lower() == WidevineCdm.urn
|
||||
)
|
||||
|
||||
init_data = track.get_init_segment(session=session)
|
||||
if init_data:
|
||||
# try get via ffprobe, needed for non mp4 data e.g. WEBM from Google Play
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
kid = UUID(bytes=base64.b64decode(enc_key_id))
|
||||
pssh_boxes.extend(list(get_boxes(init_data, b"pssh")))
|
||||
tenc_boxes.extend(list(get_boxes(init_data, b"tenc")))
|
||||
|
||||
pssh_boxes.sort(key=lambda b: {PSSH.SystemId.Widevine: 0, PSSH.SystemId.PlayReady: 1}[b.system_ID])
|
||||
|
||||
pssh = next(iter(pssh_boxes), None)
|
||||
if not pssh:
|
||||
raise Widevine.Exceptions.PSSHNotFound("PSSH was not found in track data.")
|
||||
|
||||
tenc = next(iter(tenc_boxes), None)
|
||||
if not kid and tenc and tenc.key_ID.int != 0:
|
||||
kid = tenc.key_ID
|
||||
|
||||
return cls(pssh=PSSH(pssh), kid=kid)
|
||||
|
||||
@classmethod
|
||||
def from_init_data(cls, init_data: bytes) -> Widevine:
|
||||
"""
|
||||
Get PSSH and KID from within Initialization Segment Data.
|
||||
|
||||
This should only be used if a PSSH could not be provided directly.
|
||||
It is *rare* to need to use this.
|
||||
|
||||
Raises:
|
||||
PSSHNotFound - If the PSSH was not found within the data.
|
||||
KIDNotFound - If the KID was not found within the data or PSSH.
|
||||
"""
|
||||
if not init_data:
|
||||
raise ValueError("Init data should be provided.")
|
||||
if not isinstance(init_data, bytes):
|
||||
raise TypeError(f"Expected init data to be bytes, not {init_data!r}")
|
||||
|
||||
kid: Optional[UUID] = None
|
||||
pssh_boxes: list[Container] = list(get_boxes(init_data, b"pssh"))
|
||||
tenc_boxes: list[Container] = list(get_boxes(init_data, b"tenc"))
|
||||
|
||||
# try get via ffprobe, needed for non mp4 data e.g. WEBM from Google Play
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
kid = UUID(bytes=base64.b64decode(enc_key_id))
|
||||
|
||||
pssh_boxes.sort(key=lambda b: {PSSH.SystemId.Widevine: 0, PSSH.SystemId.PlayReady: 1}[b.system_ID])
|
||||
|
||||
pssh = next(iter(pssh_boxes), None)
|
||||
if not pssh:
|
||||
raise Widevine.Exceptions.PSSHNotFound("PSSH was not found in track data.")
|
||||
|
||||
tenc = next(iter(tenc_boxes), None)
|
||||
if not kid and tenc and tenc.key_ID.int != 0:
|
||||
kid = tenc.key_ID
|
||||
|
||||
return cls(pssh=PSSH(pssh), kid=kid)
|
||||
|
||||
@property
|
||||
def pssh(self) -> PSSH:
|
||||
"""Get Protection System Specific Header Box."""
|
||||
return self._pssh
|
||||
|
||||
@property
|
||||
def kid(self) -> Optional[UUID]:
|
||||
"""Get first Key ID, if any."""
|
||||
return next(iter(self.kids), None)
|
||||
|
||||
@property
|
||||
def kids(self) -> list[UUID]:
|
||||
"""Get all Key IDs."""
|
||||
return self._pssh.key_ids
|
||||
|
||||
def get_content_keys(self, cdm: WidevineCdm, certificate: Callable, licence: Callable) -> None:
|
||||
"""
|
||||
Create a CDM Session and obtain Content Keys for this DRM Instance.
|
||||
The certificate and license params are expected to be a function and will
|
||||
be provided with the challenge and session ID.
|
||||
"""
|
||||
for kid in self.kids:
|
||||
if kid in self.content_keys:
|
||||
continue
|
||||
|
||||
session_id = cdm.open()
|
||||
|
||||
try:
|
||||
cert = certificate(challenge=cdm.service_certificate_challenge)
|
||||
if cert and hasattr(cdm, "set_service_certificate"):
|
||||
cdm.set_service_certificate(session_id, cert)
|
||||
|
||||
cdm.parse_license(session_id, licence(challenge=cdm.get_license_challenge(session_id, self.pssh)))
|
||||
|
||||
self.content_keys = {key.kid: key.key.hex() for key in cdm.get_keys(session_id, "CONTENT")}
|
||||
if not self.content_keys:
|
||||
raise Widevine.Exceptions.EmptyLicense("No Content Keys were within the License")
|
||||
|
||||
if kid not in self.content_keys:
|
||||
raise Widevine.Exceptions.CEKNotFound(f"No Content Key for KID {kid.hex} within the License")
|
||||
finally:
|
||||
cdm.close(session_id)
|
||||
|
||||
def get_NF_content_keys(self, cdm: WidevineCdm, certificate: Callable, licence: Callable) -> None:
|
||||
"""
|
||||
Create a CDM Session and obtain Content Keys for this DRM Instance.
|
||||
The certificate and license params are expected to be a function and will
|
||||
be provided with the challenge and session ID.
|
||||
"""
|
||||
for kid in self.kids:
|
||||
if kid in self.content_keys:
|
||||
continue
|
||||
|
||||
session_id = cdm.open()
|
||||
|
||||
try:
|
||||
cert = certificate(challenge=cdm.service_certificate_challenge)
|
||||
if cert and hasattr(cdm, "set_service_certificate"):
|
||||
cdm.set_service_certificate(session_id, cert)
|
||||
|
||||
cdm.parse_license(
|
||||
session_id,
|
||||
licence(session_id=session_id, challenge=cdm.get_license_challenge(session_id, self.pssh)),
|
||||
)
|
||||
|
||||
self.content_keys = {key.kid: key.key.hex() for key in cdm.get_keys(session_id, "CONTENT")}
|
||||
if not self.content_keys:
|
||||
raise Widevine.Exceptions.EmptyLicense("No Content Keys were within the License")
|
||||
|
||||
if kid not in self.content_keys:
|
||||
raise Widevine.Exceptions.CEKNotFound(f"No Content Key for KID {kid.hex} within the License")
|
||||
finally:
|
||||
cdm.close(session_id)
|
||||
|
||||
def decrypt(self, path: Path) -> None:
|
||||
"""
|
||||
Decrypt a Track with Widevine DRM.
|
||||
Raises:
|
||||
EnvironmentError if the Shaka Packager executable could not be found.
|
||||
ValueError if the track has not yet been downloaded.
|
||||
SubprocessError if Shaka Packager returned a non-zero exit code.
|
||||
"""
|
||||
if not self.content_keys:
|
||||
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
||||
|
||||
if not binaries.ShakaPackager:
|
||||
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
||||
if not path or not path.exists():
|
||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||
|
||||
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
arguments = [
|
||||
f"input={path},stream=0,output={output_path},output_format=MP4",
|
||||
"--enable_raw_key_decryption",
|
||||
"--keys",
|
||||
",".join(
|
||||
[
|
||||
*[
|
||||
"label={}:key_id={}:key={}".format(i, kid.hex, key.lower())
|
||||
for i, (kid, key) in enumerate(self.content_keys.items())
|
||||
],
|
||||
*[
|
||||
# some services use a blank KID on the file, but real KID for license server
|
||||
"label={}:key_id={}:key={}".format(i, "00" * 16, key.lower())
|
||||
for i, (kid, key) in enumerate(self.content_keys.items(), len(self.content_keys))
|
||||
],
|
||||
]
|
||||
),
|
||||
"--temp_dir",
|
||||
config.directories.temp,
|
||||
]
|
||||
|
||||
p = subprocess.Popen(
|
||||
[binaries.ShakaPackager, *arguments],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
stream_skipped = False
|
||||
had_error = False
|
||||
|
||||
shaka_log_buffer = ""
|
||||
for line in iter(p.stderr.readline, ""):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "Skip stream" in line:
|
||||
# file/segment was so small that it didn't have any actual data, ignore
|
||||
stream_skipped = True
|
||||
if ":INFO:" in line:
|
||||
continue
|
||||
if "I0" in line or "W0" in line:
|
||||
continue
|
||||
if ":ERROR:" in line:
|
||||
had_error = True
|
||||
if "Insufficient bits in bitstream for given AVC profile" in line:
|
||||
# this is a warning and is something we don't have to worry about
|
||||
continue
|
||||
shaka_log_buffer += f"{line.strip()}\n"
|
||||
|
||||
if shaka_log_buffer:
|
||||
# wrap to console width - padding - '[Widevine]: '
|
||||
shaka_log_buffer = "\n ".join(
|
||||
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
|
||||
)
|
||||
console.log(Text.from_ansi("\n[Widevine]: " + shaka_log_buffer))
|
||||
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0 or had_error:
|
||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
||||
|
||||
path.unlink()
|
||||
if not stream_skipped:
|
||||
shutil.move(output_path, path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode == 0xC000013A: # STATUS_CONTROL_C_EXIT
|
||||
raise KeyboardInterrupt()
|
||||
raise
|
||||
|
||||
class Exceptions:
|
||||
class PSSHNotFound(Exception):
|
||||
"""PSSH (Protection System Specific Header) was not found."""
|
||||
|
||||
class KIDNotFound(Exception):
|
||||
"""KID (Encryption Key ID) was not found."""
|
||||
|
||||
class CEKNotFound(Exception):
|
||||
"""CEK (Content Encryption Key) for KID was not found in License."""
|
||||
|
||||
class EmptyLicense(Exception):
|
||||
"""License returned no Content Encryption Keys."""
|
||||
|
||||
|
||||
__all__ = ("Widevine",)
|
||||
76
unshackle/core/events.py
Normal file
76
unshackle/core/events.py
Normal file
@@ -0,0 +1,76 @@
|
||||
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()
|
||||
5
unshackle/core/manifests/__init__.py
Normal file
5
unshackle/core/manifests/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .dash import DASH
|
||||
from .hls import HLS
|
||||
from .ism import ISM
|
||||
|
||||
__all__ = ("DASH", "HLS", "ISM")
|
||||
800
unshackle/core/manifests/dash.py
Normal file
800
unshackle/core/manifests/dash.py
Normal file
@@ -0,0 +1,800 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
import requests
|
||||
from langcodes import Language, tag_is_valid
|
||||
from lxml.etree import Element, ElementTree
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||
from unshackle.core.downloaders import requests as requests_downloader
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from unshackle.core.utilities import is_close_match, try_ensure_utf8
|
||||
from unshackle.core.utils.xml import load_xml
|
||||
|
||||
|
||||
class DASH:
|
||||
def __init__(self, manifest, url: str):
|
||||
if manifest is None:
|
||||
raise ValueError("DASH manifest must be provided.")
|
||||
if manifest.tag != "MPD":
|
||||
raise TypeError(f"Expected 'MPD' document, but received a '{manifest.tag}' document instead.")
|
||||
|
||||
if not url:
|
||||
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
|
||||
if not isinstance(url, str):
|
||||
raise TypeError(f"Expected url to be a {str}, not {url!r}")
|
||||
|
||||
self.manifest = manifest
|
||||
self.url = url
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Session] = None, **args: Any) -> DASH:
|
||||
if not url:
|
||||
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
|
||||
if not isinstance(url, str):
|
||||
raise TypeError(f"Expected url to be a {str}, not {url!r}")
|
||||
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, Session):
|
||||
raise TypeError(f"Expected session to be a {Session}, not {session!r}")
|
||||
|
||||
res = session.get(url, **args)
|
||||
if res.url != url:
|
||||
url = res.url
|
||||
|
||||
if not res.ok:
|
||||
raise requests.ConnectionError("Failed to request the MPD document.", response=res)
|
||||
|
||||
return DASH.from_text(res.text, url)
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str, url: str) -> DASH:
|
||||
if not text:
|
||||
raise ValueError("DASH manifest Text must be provided.")
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(f"Expected text to be a {str}, not {text!r}")
|
||||
|
||||
if not url:
|
||||
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
|
||||
if not isinstance(url, str):
|
||||
raise TypeError(f"Expected url to be a {str}, not {url!r}")
|
||||
|
||||
manifest = load_xml(text)
|
||||
|
||||
return cls(manifest, url)
|
||||
|
||||
def to_tracks(
|
||||
self, language: Optional[Union[str, Language]] = None, period_filter: Optional[Callable] = None
|
||||
) -> Tracks:
|
||||
"""
|
||||
Convert an MPEG-DASH document to Video, Audio and Subtitle Track objects.
|
||||
|
||||
Parameters:
|
||||
language: The Title's Original Recorded Language. It will also be used as a fallback
|
||||
track language value if the manifest does not list language information.
|
||||
period_filter: Filter out period's within the manifest.
|
||||
|
||||
All Track URLs will be a list of segment URLs.
|
||||
"""
|
||||
tracks = Tracks()
|
||||
|
||||
for period in self.manifest.findall("Period"):
|
||||
if callable(period_filter) and period_filter(period):
|
||||
continue
|
||||
if next(iter(period.xpath("SegmentType/@value")), "content") != "content":
|
||||
continue
|
||||
|
||||
for adaptation_set in period.findall("AdaptationSet"):
|
||||
if self.is_trick_mode(adaptation_set):
|
||||
# we don't want trick mode streams (they are only used for fast-forward/rewind)
|
||||
continue
|
||||
|
||||
for rep in adaptation_set.findall("Representation"):
|
||||
get = partial(self._get, adaptation_set=adaptation_set, representation=rep)
|
||||
findall = partial(self._findall, adaptation_set=adaptation_set, representation=rep, both=True)
|
||||
segment_base = rep.find("SegmentBase")
|
||||
|
||||
codecs = get("codecs")
|
||||
content_type = get("contentType")
|
||||
mime_type = get("mimeType")
|
||||
|
||||
if not content_type and mime_type:
|
||||
content_type = mime_type.split("/")[0]
|
||||
if not content_type and not mime_type:
|
||||
raise ValueError("Unable to determine the format of a Representation, cannot continue...")
|
||||
|
||||
if mime_type == "application/mp4" or content_type == "application":
|
||||
# likely mp4-boxed subtitles
|
||||
# TODO: It may not actually be subtitles
|
||||
try:
|
||||
real_codec = Subtitle.Codec.from_mime(codecs)
|
||||
content_type = "text"
|
||||
mime_type = f"application/mp4; codecs='{real_codec.value.lower()}'"
|
||||
except ValueError:
|
||||
raise ValueError(f"Unsupported content type '{content_type}' with codecs of '{codecs}'")
|
||||
|
||||
if content_type == "text" and mime_type and "/mp4" not in mime_type:
|
||||
# mimeType likely specifies the subtitle codec better than `codecs`
|
||||
codecs = mime_type.split("/")[1]
|
||||
|
||||
if content_type == "video":
|
||||
track_type = Video
|
||||
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(
|
||||
range_=self.get_video_range(
|
||||
codecs, findall("SupplementalProperty"), findall("EssentialProperty")
|
||||
),
|
||||
bitrate=get("bandwidth") or None,
|
||||
width=get("width") or 0,
|
||||
height=get("height") or 0,
|
||||
fps=track_fps or None,
|
||||
)
|
||||
elif content_type == "audio":
|
||||
track_type = Audio
|
||||
track_codec = Audio.Codec.from_codecs(codecs)
|
||||
track_args = dict(
|
||||
bitrate=get("bandwidth") or None,
|
||||
channels=next(
|
||||
iter(
|
||||
rep.xpath("AudioChannelConfiguration/@value")
|
||||
or adaptation_set.xpath("AudioChannelConfiguration/@value")
|
||||
),
|
||||
None,
|
||||
),
|
||||
joc=self.get_ddp_complexity_index(adaptation_set, rep),
|
||||
descriptive=self.is_descriptive(adaptation_set),
|
||||
)
|
||||
elif content_type == "text":
|
||||
track_type = Subtitle
|
||||
track_codec = Subtitle.Codec.from_codecs(codecs or "vtt")
|
||||
track_args = dict(
|
||||
cc=self.is_closed_caption(adaptation_set),
|
||||
sdh=self.is_sdh(adaptation_set),
|
||||
forced=self.is_forced(adaptation_set),
|
||||
)
|
||||
elif content_type == "image":
|
||||
# we don't want what's likely thumbnails for the seekbar
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"Unknown Track Type '{content_type}'")
|
||||
|
||||
track_lang = self.get_language(adaptation_set, rep, fallback=language)
|
||||
if not track_lang:
|
||||
msg = "Language information could not be derived from a Representation."
|
||||
if language is None:
|
||||
msg += " No fallback language was provided when calling DASH.to_tracks()."
|
||||
elif not tag_is_valid((str(language) or "").strip()) or str(language).startswith("und"):
|
||||
msg += f" The fallback language provided is also invalid: {language}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# for some reason it's incredibly common for services to not provide
|
||||
# a good and actually unique track ID, sometimes because of the lang
|
||||
# dialect not being represented in the id, or the bitrate, or such.
|
||||
# this combines all of them as one and hashes it to keep it small(ish).
|
||||
track_id = hex(
|
||||
crc32(
|
||||
"{codec}-{lang}-{bitrate}-{base_url}-{ids}-{track_args}".format(
|
||||
codec=codecs,
|
||||
lang=track_lang,
|
||||
bitrate=get("bitrate"),
|
||||
base_url=(rep.findtext("BaseURL") or "").split("?")[0],
|
||||
ids=[get("audioTrackId"), get("id"), period.get("id")],
|
||||
track_args=track_args,
|
||||
).encode()
|
||||
)
|
||||
)[2:]
|
||||
|
||||
tracks.add(
|
||||
track_type(
|
||||
id_=track_id,
|
||||
url=self.url,
|
||||
codec=track_codec,
|
||||
language=track_lang,
|
||||
is_original_lang=bool(language and is_close_match(track_lang, [language])),
|
||||
descriptor=Video.Descriptor.DASH,
|
||||
data={
|
||||
"dash": {
|
||||
"manifest": self.manifest,
|
||||
"period": period,
|
||||
"adaptation_set": adaptation_set,
|
||||
"representation": rep,
|
||||
}
|
||||
},
|
||||
**track_args,
|
||||
)
|
||||
)
|
||||
|
||||
# only get tracks from the first main-content period
|
||||
break
|
||||
|
||||
return tracks
|
||||
|
||||
@staticmethod
|
||||
def download_track(
|
||||
track: AnyTrack,
|
||||
save_path: Path,
|
||||
save_dir: Path,
|
||||
progress: partial,
|
||||
session: Optional[Session] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
license_widevine: Optional[Callable] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
):
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, Session):
|
||||
raise TypeError(f"Expected session to be a {Session}, not {session!r}")
|
||||
|
||||
if proxy:
|
||||
session.proxies.update({"all": proxy})
|
||||
|
||||
log = logging.getLogger("DASH")
|
||||
|
||||
manifest: ElementTree = track.data["dash"]["manifest"]
|
||||
period: Element = track.data["dash"]["period"]
|
||||
adaptation_set: Element = track.data["dash"]["adaptation_set"]
|
||||
representation: Element = track.data["dash"]["representation"]
|
||||
|
||||
# Preserve existing DRM if it was set by the service, especially when service set Widevine
|
||||
# but manifest only contains PlayReady protection (common scenario for some services)
|
||||
existing_drm = track.drm
|
||||
manifest_drm = DASH.get_drm(
|
||||
representation.findall("ContentProtection") + adaptation_set.findall("ContentProtection")
|
||||
)
|
||||
|
||||
# Only override existing DRM if:
|
||||
# 1. No existing DRM was set, OR
|
||||
# 2. Existing DRM contains same type as manifest DRM, OR
|
||||
# 3. Existing DRM is not Widevine (preserve Widevine when service explicitly set it)
|
||||
should_override_drm = (
|
||||
not existing_drm
|
||||
or (
|
||||
existing_drm
|
||||
and manifest_drm
|
||||
and any(isinstance(existing, type(manifest)) for existing in existing_drm for manifest in manifest_drm)
|
||||
)
|
||||
or (existing_drm and not any(isinstance(drm, Widevine) for drm in existing_drm))
|
||||
)
|
||||
|
||||
if should_override_drm:
|
||||
track.drm = manifest_drm
|
||||
else:
|
||||
track.drm = existing_drm
|
||||
|
||||
manifest_base_url = manifest.findtext("BaseURL")
|
||||
if not manifest_base_url:
|
||||
manifest_base_url = track.url
|
||||
elif not re.match("^https?://", manifest_base_url, re.IGNORECASE):
|
||||
manifest_base_url = urljoin(track.url, f"./{manifest_base_url}")
|
||||
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL"))
|
||||
rep_base_url = urljoin(period_base_url, representation.findtext("BaseURL"))
|
||||
|
||||
period_duration = period.get("duration") or manifest.get("mediaPresentationDuration")
|
||||
init_data: Optional[bytes] = None
|
||||
|
||||
segment_template = representation.find("SegmentTemplate")
|
||||
if segment_template is None:
|
||||
segment_template = adaptation_set.find("SegmentTemplate")
|
||||
|
||||
segment_list = representation.find("SegmentList")
|
||||
if segment_list is None:
|
||||
segment_list = adaptation_set.find("SegmentList")
|
||||
|
||||
segment_base = representation.find("SegmentBase")
|
||||
if segment_base is None:
|
||||
segment_base = adaptation_set.find("SegmentBase")
|
||||
|
||||
segments: list[tuple[str, Optional[str]]] = []
|
||||
segment_timescale: float = 0
|
||||
segment_durations: list[int] = []
|
||||
track_kid: Optional[UUID] = None
|
||||
|
||||
if segment_template is not None:
|
||||
segment_template = copy(segment_template)
|
||||
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_timescale = float(segment_template.get("timescale") or 1)
|
||||
|
||||
for item in ("initialization", "media"):
|
||||
value = segment_template.get(item)
|
||||
if not value:
|
||||
continue
|
||||
if not re.match("^https?://", value, re.IGNORECASE):
|
||||
if not rep_base_url:
|
||||
raise ValueError("Resolved Segment URL is not absolute, and no Base URL is available.")
|
||||
value = urljoin(rep_base_url, value)
|
||||
if not urlparse(value).query:
|
||||
manifest_url_query = urlparse(track.url).query
|
||||
if manifest_url_query:
|
||||
value += f"?{manifest_url_query}"
|
||||
segment_template.set(item, value)
|
||||
|
||||
init_url = segment_template.get("initialization")
|
||||
if init_url:
|
||||
res = session.get(
|
||||
DASH.replace_fields(
|
||||
init_url, Bandwidth=representation.get("bandwidth"), RepresentationID=representation.get("id")
|
||||
)
|
||||
)
|
||||
res.raise_for_status()
|
||||
init_data = res.content
|
||||
track_kid = track.get_key_id(init_data)
|
||||
|
||||
if segment_timeline is not None:
|
||||
current_time = 0
|
||||
for s in segment_timeline.findall("S"):
|
||||
if s.get("t"):
|
||||
current_time = int(s.get("t"))
|
||||
for _ in range(1 + (int(s.get("r") or 0))):
|
||||
segment_durations.append(current_time)
|
||||
current_time += int(s.get("d"))
|
||||
|
||||
if not end_number:
|
||||
end_number = len(segment_durations)
|
||||
|
||||
for t, n in zip(segment_durations, range(start_number, end_number + 1)):
|
||||
segments.append(
|
||||
(
|
||||
DASH.replace_fields(
|
||||
segment_template.get("media"),
|
||||
Bandwidth=representation.get("bandwidth"),
|
||||
Number=n,
|
||||
RepresentationID=representation.get("id"),
|
||||
Time=t,
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not period_duration:
|
||||
raise ValueError("Duration of the Period was unable to be determined.")
|
||||
period_duration = DASH.pt_to_sec(period_duration)
|
||||
segment_duration = float(segment_template.get("duration")) or 1
|
||||
|
||||
if not end_number:
|
||||
end_number = math.ceil(period_duration / (segment_duration / segment_timescale))
|
||||
|
||||
for s in range(start_number, end_number + 1):
|
||||
segments.append(
|
||||
(
|
||||
DASH.replace_fields(
|
||||
segment_template.get("media"),
|
||||
Bandwidth=representation.get("bandwidth"),
|
||||
Number=s,
|
||||
RepresentationID=representation.get("id"),
|
||||
Time=s,
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
# TODO: Should we floor/ceil/round, or is int() ok?
|
||||
segment_durations.append(int(segment_duration))
|
||||
elif segment_list is not None:
|
||||
segment_timescale = float(segment_list.get("timescale") or 1)
|
||||
|
||||
init_data = None
|
||||
initialization = segment_list.find("Initialization")
|
||||
if initialization is not None:
|
||||
source_url = initialization.get("sourceURL")
|
||||
if not source_url:
|
||||
source_url = rep_base_url
|
||||
elif not re.match("^https?://", source_url, re.IGNORECASE):
|
||||
source_url = urljoin(rep_base_url, f"./{source_url}")
|
||||
|
||||
if initialization.get("range"):
|
||||
init_range_header = {"Range": f"bytes={initialization.get('range')}"}
|
||||
else:
|
||||
init_range_header = None
|
||||
|
||||
res = session.get(url=source_url, headers=init_range_header)
|
||||
res.raise_for_status()
|
||||
init_data = res.content
|
||||
track_kid = track.get_key_id(init_data)
|
||||
|
||||
segment_urls = segment_list.findall("SegmentURL")
|
||||
for segment_url in segment_urls:
|
||||
media_url = segment_url.get("media")
|
||||
if not media_url:
|
||||
media_url = rep_base_url
|
||||
elif not re.match("^https?://", media_url, re.IGNORECASE):
|
||||
media_url = urljoin(rep_base_url, f"./{media_url}")
|
||||
|
||||
segments.append((media_url, segment_url.get("mediaRange")))
|
||||
segment_durations.append(int(segment_url.get("duration") or 1))
|
||||
elif segment_base is not None:
|
||||
media_range = None
|
||||
init_data = None
|
||||
initialization = segment_base.find("Initialization")
|
||||
if initialization is not None:
|
||||
if initialization.get("range"):
|
||||
init_range_header = {"Range": f"bytes={initialization.get('range')}"}
|
||||
else:
|
||||
init_range_header = None
|
||||
|
||||
res = session.get(url=rep_base_url, headers=init_range_header)
|
||||
res.raise_for_status()
|
||||
init_data = res.content
|
||||
track_kid = track.get_key_id(init_data)
|
||||
total_size = res.headers.get("Content-Range", "").split("/")[-1]
|
||||
if total_size:
|
||||
media_range = f"{len(init_data)}-{total_size}"
|
||||
|
||||
segments.append((rep_base_url, media_range))
|
||||
elif rep_base_url:
|
||||
segments.append((rep_base_url, None))
|
||||
else:
|
||||
log.error("Could not find a way to get segments from this MPD manifest.")
|
||||
log.debug(track.url)
|
||||
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)):
|
||||
try:
|
||||
track.drm = [Widevine.from_init_data(init_data)]
|
||||
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 track.drm:
|
||||
track_kid = track_kid or track.get_key_id(url=segments[0][0], session=session)
|
||||
drm = track.get_drm_for_cdm(cdm)
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
# license and grab content keys
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
else:
|
||||
drm = None
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
return
|
||||
|
||||
progress(total=len(segments))
|
||||
|
||||
downloader = track.downloader
|
||||
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
|
||||
downloader = requests_downloader
|
||||
log.warning("Falling back to the requests downloader as aria2(c) doesn't support the Range header")
|
||||
|
||||
downloader_args = dict(
|
||||
urls=[
|
||||
{"url": url, "headers": {"Range": f"bytes={bytes_range}"} if bytes_range else {}}
|
||||
for url, bytes_range in segments
|
||||
],
|
||||
output_dir=save_dir,
|
||||
filename="{i:0%d}.mp4" % (len(str(len(segments)))),
|
||||
headers=session.headers,
|
||||
cookies=session.cookies,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
)
|
||||
|
||||
if downloader.__name__ == "n_m3u8dl_re":
|
||||
downloader_args.update({"filename": track.id, "track": track})
|
||||
|
||||
for status_update in downloader(**downloader_args):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if file_downloaded:
|
||||
events.emit(events.Types.SEGMENT_DOWNLOADED, track=track, segment=file_downloaded)
|
||||
else:
|
||||
downloaded = status_update.get("downloaded")
|
||||
if downloaded and downloaded.endswith("/s"):
|
||||
status_update["downloaded"] = f"DASH {downloaded}"
|
||||
progress(**status_update)
|
||||
|
||||
# see https://github.com/devine-dl/devine/issues/71
|
||||
for control_file in save_dir.glob("*.aria2__temp"):
|
||||
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:
|
||||
if 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:
|
||||
segment_data = segment_file.read_bytes()
|
||||
# TODO: fix encoding after decryption?
|
||||
if (
|
||||
not drm
|
||||
and isinstance(track, Subtitle)
|
||||
and track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML)
|
||||
):
|
||||
segment_data = try_ensure_utf8(segment_data)
|
||||
segment_data = (
|
||||
segment_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
f.write(segment_data)
|
||||
f.flush()
|
||||
segment_file.unlink()
|
||||
progress(advance=1)
|
||||
|
||||
track.path = save_path
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=track)
|
||||
|
||||
if drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=100)
|
||||
drm.decrypt(save_path)
|
||||
track.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=None)
|
||||
progress(downloaded="Decrypting", advance=100)
|
||||
|
||||
save_dir.rmdir()
|
||||
|
||||
progress(downloaded="Downloaded")
|
||||
|
||||
@staticmethod
|
||||
def _get(item: str, adaptation_set: Element, representation: Optional[Element] = None) -> Optional[Any]:
|
||||
"""Helper to get a requested item from the Representation, otherwise from the AdaptationSet."""
|
||||
adaptation_set_item = adaptation_set.get(item)
|
||||
if representation is None:
|
||||
return adaptation_set_item
|
||||
|
||||
representation_item = representation.get(item)
|
||||
if representation_item is not None:
|
||||
return representation_item
|
||||
|
||||
return adaptation_set_item
|
||||
|
||||
@staticmethod
|
||||
def _findall(
|
||||
item: str, adaptation_set: Element, representation: Optional[Element] = None, both: bool = False
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Helper to get all requested items from the Representation, otherwise from the AdaptationSet.
|
||||
Optionally, you may pass both=True to keep both values (where available).
|
||||
"""
|
||||
adaptation_set_items = adaptation_set.findall(item)
|
||||
if representation is None:
|
||||
return adaptation_set_items
|
||||
|
||||
representation_items = representation.findall(item)
|
||||
|
||||
if both:
|
||||
return representation_items + adaptation_set_items
|
||||
|
||||
if representation_items:
|
||||
return representation_items
|
||||
|
||||
return adaptation_set_items
|
||||
|
||||
@staticmethod
|
||||
def get_language(
|
||||
adaptation_set: Element,
|
||||
representation: Optional[Element] = None,
|
||||
fallback: Optional[Union[str, Language]] = None,
|
||||
) -> Optional[Language]:
|
||||
"""
|
||||
Get Language (if any) from the AdaptationSet or Representation.
|
||||
|
||||
A fallback language may be provided if no language information could be
|
||||
retrieved.
|
||||
"""
|
||||
options = []
|
||||
|
||||
if representation is not None:
|
||||
options.append(representation.get("lang"))
|
||||
# derive language from somewhat common id string format
|
||||
# the format is typically "{rep_id}_{lang}={bitrate}" or similar
|
||||
rep_id = representation.get("id")
|
||||
if rep_id:
|
||||
m = re.match(r"\w+_(\w+)=\d+", rep_id)
|
||||
if m:
|
||||
options.append(m.group(1))
|
||||
|
||||
options.append(adaptation_set.get("lang"))
|
||||
|
||||
if fallback:
|
||||
options.append(fallback)
|
||||
|
||||
for option in options:
|
||||
option = (str(option) or "").strip()
|
||||
if not tag_is_valid(option) or option.startswith("und"):
|
||||
continue
|
||||
return Language.get(option)
|
||||
|
||||
@staticmethod
|
||||
def get_video_range(
|
||||
codecs: str, all_supplemental_props: list[Element], all_essential_props: list[Element]
|
||||
) -> Video.Range:
|
||||
if codecs.startswith(("dva1", "dvav", "dvhe", "dvh1")):
|
||||
return Video.Range.DV
|
||||
|
||||
return Video.Range.from_cicp(
|
||||
primaries=next(
|
||||
(
|
||||
int(x.get("value"))
|
||||
for x in all_supplemental_props + all_essential_props
|
||||
if x.get("schemeIdUri") == "urn:mpeg:mpegB:cicp:ColourPrimaries"
|
||||
),
|
||||
0,
|
||||
),
|
||||
transfer=next(
|
||||
(
|
||||
int(x.get("value"))
|
||||
for x in all_supplemental_props + all_essential_props
|
||||
if x.get("schemeIdUri") == "urn:mpeg:mpegB:cicp:TransferCharacteristics"
|
||||
),
|
||||
0,
|
||||
),
|
||||
matrix=next(
|
||||
(
|
||||
int(x.get("value"))
|
||||
for x in all_supplemental_props + all_essential_props
|
||||
if x.get("schemeIdUri") == "urn:mpeg:mpegB:cicp:MatrixCoefficients"
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_trick_mode(adaptation_set: Element) -> bool:
|
||||
"""Check if contents of Adaptation Set is a Trick-Mode stream."""
|
||||
essential_props = adaptation_set.findall("EssentialProperty")
|
||||
supplemental_props = adaptation_set.findall("SupplementalProperty")
|
||||
|
||||
return any(
|
||||
prop.get("schemeIdUri") == "http://dashif.org/guidelines/trickmode"
|
||||
for prop in essential_props + supplemental_props
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_descriptive(adaptation_set: Element) -> bool:
|
||||
"""Check if contents of Adaptation Set is Descriptive."""
|
||||
return any(
|
||||
(x.get("schemeIdUri"), x.get("value"))
|
||||
in (("urn:mpeg:dash:role:2011", "descriptive"), ("urn:tva:metadata:cs:AudioPurposeCS:2007", "1"))
|
||||
for x in adaptation_set.findall("Accessibility")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_forced(adaptation_set: Element) -> bool:
|
||||
"""Check if contents of Adaptation Set is a Forced Subtitle."""
|
||||
return any(
|
||||
x.get("schemeIdUri") == "urn:mpeg:dash:role:2011"
|
||||
and x.get("value") in ("forced-subtitle", "forced_subtitle")
|
||||
for x in adaptation_set.findall("Role")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_sdh(adaptation_set: Element) -> bool:
|
||||
"""Check if contents of Adaptation Set is for the Hearing Impaired."""
|
||||
return any(
|
||||
(x.get("schemeIdUri"), x.get("value")) == ("urn:tva:metadata:cs:AudioPurposeCS:2007", "2")
|
||||
for x in adaptation_set.findall("Accessibility")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_closed_caption(adaptation_set: Element) -> bool:
|
||||
"""Check if contents of Adaptation Set is a Closed Caption Subtitle."""
|
||||
return any(
|
||||
(x.get("schemeIdUri"), x.get("value")) == ("urn:mpeg:dash:role:2011", "caption")
|
||||
for x in adaptation_set.findall("Role")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_ddp_complexity_index(adaptation_set: Element, representation: Optional[Element]) -> Optional[int]:
|
||||
"""Get the DD+ Complexity Index (if any) from the AdaptationSet or Representation."""
|
||||
return next(
|
||||
(
|
||||
int(x.get("value"))
|
||||
for x in DASH._findall("SupplementalProperty", adaptation_set, representation, both=True)
|
||||
if x.get("schemeIdUri") == "tag:dolby.com,2018:dash:EC3_ExtensionComplexityIndex:2018"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_drm(protections: list[Element]) -> list[DRM_T]:
|
||||
drm: list[DRM_T] = []
|
||||
|
||||
for protection in protections:
|
||||
urn = (protection.get("schemeIdUri") or "").lower()
|
||||
|
||||
if urn == WidevineCdm.urn:
|
||||
pssh_text = protection.findtext("pssh")
|
||||
if not pssh_text:
|
||||
continue
|
||||
pssh = PSSH(pssh_text)
|
||||
|
||||
kid = protection.get("kid")
|
||||
if kid:
|
||||
kid = UUID(bytes=base64.b64decode(kid))
|
||||
|
||||
default_kid = protection.get("default_KID")
|
||||
if default_kid:
|
||||
kid = UUID(default_kid)
|
||||
|
||||
if not pssh.key_ids and not kid:
|
||||
kid = next((UUID(p.get("default_KID")) for p in protections if p.get("default_KID")), None)
|
||||
|
||||
drm.append(Widevine(pssh=pssh, kid=kid))
|
||||
|
||||
elif urn in ("urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95", "urn:microsoft:playready"):
|
||||
pr_pssh_b64 = (
|
||||
protection.findtext("pssh")
|
||||
or protection.findtext("pro")
|
||||
or protection.findtext("{urn:microsoft:playready}pro")
|
||||
)
|
||||
if not pr_pssh_b64:
|
||||
continue
|
||||
pr_pssh = PR_PSSH(pr_pssh_b64)
|
||||
kid_b64 = protection.findtext("kid")
|
||||
kid = None
|
||||
if kid_b64:
|
||||
try:
|
||||
kid = UUID(bytes=base64.b64decode(kid_b64))
|
||||
except Exception:
|
||||
kid = None
|
||||
|
||||
drm.append(PlayReady(pssh=pr_pssh, kid=kid, pssh_b64=pr_pssh_b64))
|
||||
|
||||
return drm
|
||||
|
||||
@staticmethod
|
||||
def pt_to_sec(d: Union[str, float]) -> float:
|
||||
if isinstance(d, float):
|
||||
return d
|
||||
has_ymd = d[0:8] == "P0Y0M0DT"
|
||||
if d[0:2] != "PT" and not has_ymd:
|
||||
raise ValueError("Input data is not a valid time string.")
|
||||
if has_ymd:
|
||||
d = d[6:].upper() # skip `P0Y0M0DT`
|
||||
else:
|
||||
d = d[2:].upper() # skip `PT`
|
||||
m = re.findall(r"([\d.]+.)", d)
|
||||
return sum(float(x[0:-1]) * {"H": 60 * 60, "M": 60, "S": 1}[x[-1].upper()] for x in m)
|
||||
|
||||
@staticmethod
|
||||
def replace_fields(url: str, **kwargs: Any) -> str:
|
||||
for field, value in kwargs.items():
|
||||
url = url.replace(f"${field}$", str(value))
|
||||
m = re.search(rf"\${re.escape(field)}%([a-z0-9]+)\$", url, flags=re.I)
|
||||
if m:
|
||||
url = url.replace(m.group(), f"{value:{m.group(1)}}")
|
||||
return url
|
||||
|
||||
|
||||
__all__ = ("DASH",)
|
||||
832
unshackle/core/manifests/hls.py
Normal file
832
unshackle/core/manifests/hls.py
Normal file
@@ -0,0 +1,832 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
from zlib import crc32
|
||||
|
||||
import httpx
|
||||
import m3u8
|
||||
import requests
|
||||
from langcodes import Language, tag_is_valid
|
||||
from m3u8 import M3U8
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH as WV_PSSH
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||
from unshackle.core.downloaders import requests as requests_downloader
|
||||
from unshackle.core.drm import DRM_T, ClearKey, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from unshackle.core.utilities import get_extension, is_close_match, try_ensure_utf8
|
||||
|
||||
|
||||
class HLS:
|
||||
def __init__(self, manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None):
|
||||
if not manifest:
|
||||
raise ValueError("HLS manifest must be provided.")
|
||||
if not isinstance(manifest, M3U8):
|
||||
raise TypeError(f"Expected manifest to be a {M3U8}, not {manifest!r}")
|
||||
if not manifest.is_variant:
|
||||
raise ValueError("Expected the M3U(8) manifest to be a Variant Playlist.")
|
||||
|
||||
self.manifest = manifest
|
||||
self.session = session or Session()
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, httpx.Client]] = None, **args: Any) -> HLS:
|
||||
if not url:
|
||||
raise requests.URLRequired("HLS manifest URL must be provided.")
|
||||
if not isinstance(url, str):
|
||||
raise TypeError(f"Expected url to be a {str}, not {url!r}")
|
||||
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}")
|
||||
|
||||
res = session.get(url, **args)
|
||||
|
||||
# Handle both requests and httpx response objects
|
||||
if isinstance(res, requests.Response):
|
||||
if not res.ok:
|
||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||
content = res.text
|
||||
elif isinstance(res, httpx.Response):
|
||||
if res.status_code >= 400:
|
||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||
content = res.text
|
||||
else:
|
||||
raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(res)}")
|
||||
|
||||
master = m3u8.loads(content, uri=url)
|
||||
|
||||
return cls(master, session)
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str, url: str) -> HLS:
|
||||
if not text:
|
||||
raise ValueError("HLS manifest Text must be provided.")
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(f"Expected text to be a {str}, not {text!r}")
|
||||
|
||||
if not url:
|
||||
raise requests.URLRequired("HLS manifest URL must be provided for relative path computations.")
|
||||
if not isinstance(url, str):
|
||||
raise TypeError(f"Expected url to be a {str}, not {url!r}")
|
||||
|
||||
master = m3u8.loads(text, uri=url)
|
||||
|
||||
return cls(master)
|
||||
|
||||
def to_tracks(self, language: Union[str, Language]) -> Tracks:
|
||||
"""
|
||||
Convert a Variant Playlist M3U(8) document to Video, Audio and Subtitle Track objects.
|
||||
|
||||
Parameters:
|
||||
language: Language you expect the Primary Track to be in.
|
||||
|
||||
All Track objects' URL will be to another M3U(8) document. However, these documents
|
||||
will be Invariant Playlists and contain the list of segments URIs among other metadata.
|
||||
"""
|
||||
session_keys = list(self.manifest.session_keys or [])
|
||||
if not session_keys:
|
||||
session_keys = HLS.parse_session_data_keys(self.manifest, self.session)
|
||||
|
||||
session_drm = HLS.get_all_drm(session_keys)
|
||||
|
||||
audio_codecs_by_group_id: dict[str, Audio.Codec] = {}
|
||||
tracks = Tracks()
|
||||
|
||||
for playlist in self.manifest.playlists:
|
||||
audio_group = playlist.stream_info.audio
|
||||
if audio_group:
|
||||
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
|
||||
audio_codecs_by_group_id[audio_group] = audio_codec
|
||||
|
||||
try:
|
||||
# TODO: Any better way to figure out the primary track type?
|
||||
if playlist.stream_info.codecs:
|
||||
Video.Codec.from_codecs(playlist.stream_info.codecs)
|
||||
except ValueError:
|
||||
primary_track_type = Audio
|
||||
else:
|
||||
primary_track_type = Video
|
||||
|
||||
tracks.add(
|
||||
primary_track_type(
|
||||
id_=hex(crc32(str(playlist).encode()))[2:],
|
||||
url=urljoin(playlist.base_uri, playlist.uri),
|
||||
codec=(
|
||||
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
|
||||
is_original_lang=True, # TODO: All we can do is assume Yes
|
||||
bitrate=playlist.stream_info.average_bandwidth or playlist.stream_info.bandwidth,
|
||||
descriptor=Video.Descriptor.HLS,
|
||||
drm=session_drm,
|
||||
data={"hls": {"playlist": playlist}},
|
||||
# video track args
|
||||
**(
|
||||
dict(
|
||||
range_=Video.Range.DV
|
||||
if any(
|
||||
codec.split(".")[0] in ("dva1", "dvav", "dvhe", "dvh1")
|
||||
for codec in (playlist.stream_info.codecs or "").lower().split(",")
|
||||
)
|
||||
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,
|
||||
height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None,
|
||||
fps=playlist.stream_info.frame_rate,
|
||||
)
|
||||
if primary_track_type is Video
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for media in self.manifest.media:
|
||||
if not media.uri:
|
||||
continue
|
||||
|
||||
joc = 0
|
||||
if media.type == "AUDIO":
|
||||
track_type = Audio
|
||||
codec = audio_codecs_by_group_id.get(media.group_id)
|
||||
if media.channels and media.channels.endswith("/JOC"):
|
||||
joc = int(media.channels.split("/JOC")[0])
|
||||
media.channels = "5.1"
|
||||
else:
|
||||
track_type = Subtitle
|
||||
codec = Subtitle.Codec.WebVTT # assuming WebVTT, codec info isn't shown
|
||||
|
||||
track_lang = next(
|
||||
(
|
||||
Language.get(option)
|
||||
for x in (media.language, language)
|
||||
for option in [(str(x) or "").strip()]
|
||||
if tag_is_valid(option) and not option.startswith("und")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not track_lang:
|
||||
msg = "Language information could not be derived for a media."
|
||||
if language is None:
|
||||
msg += " No fallback language was provided when calling HLS.to_tracks()."
|
||||
elif not tag_is_valid((str(language) or "").strip()) or str(language).startswith("und"):
|
||||
msg += f" The fallback language provided is also invalid: {language}"
|
||||
raise ValueError(msg)
|
||||
|
||||
tracks.add(
|
||||
track_type(
|
||||
id_=hex(crc32(str(media).encode()))[2:],
|
||||
url=urljoin(media.base_uri, media.uri),
|
||||
codec=codec,
|
||||
language=track_lang, # HLS media may not have language info, fallback if needed
|
||||
is_original_lang=bool(language and is_close_match(track_lang, [language])),
|
||||
descriptor=Audio.Descriptor.HLS,
|
||||
drm=session_drm if media.type == "AUDIO" else None,
|
||||
data={"hls": {"media": media}},
|
||||
# audio track args
|
||||
**(
|
||||
dict(
|
||||
bitrate=0, # TODO: M3U doesn't seem to state bitrate?
|
||||
channels=media.channels,
|
||||
joc=joc,
|
||||
descriptive="public.accessibility.describes-video" in (media.characteristics or ""),
|
||||
)
|
||||
if track_type is Audio
|
||||
else dict(
|
||||
forced=media.forced == "YES",
|
||||
sdh="public.accessibility.describes-music-and-sound" in (media.characteristics or ""),
|
||||
)
|
||||
if track_type is Subtitle
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tracks
|
||||
|
||||
@staticmethod
|
||||
def download_track(
|
||||
track: AnyTrack,
|
||||
save_path: Path,
|
||||
save_dir: Path,
|
||||
progress: partial,
|
||||
session: Optional[Union[Session, httpx.Client]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
license_widevine: Optional[Callable] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
) -> None:
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}")
|
||||
|
||||
if proxy:
|
||||
# Handle proxies differently based on session type
|
||||
if isinstance(session, Session):
|
||||
session.proxies.update({"all": proxy})
|
||||
elif isinstance(session, httpx.Client):
|
||||
session.proxies = {"http://": proxy, "https://": proxy}
|
||||
|
||||
log = logging.getLogger("HLS")
|
||||
|
||||
# Get the playlist text and handle both session types
|
||||
response = session.get(track.url)
|
||||
if isinstance(response, requests.Response):
|
||||
if not response.ok:
|
||||
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
||||
sys.exit(1)
|
||||
playlist_text = response.text
|
||||
elif isinstance(response, httpx.Response):
|
||||
if response.status_code >= 400:
|
||||
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
||||
sys.exit(1)
|
||||
playlist_text = response.text
|
||||
else:
|
||||
raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(response)}")
|
||||
|
||||
master = m3u8.loads(playlist_text, uri=track.url)
|
||||
|
||||
if not master.segments:
|
||||
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
||||
sys.exit(1)
|
||||
|
||||
if track.drm:
|
||||
session_drm = track.get_drm_for_cdm(cdm)
|
||||
if isinstance(session_drm, (Widevine, PlayReady)):
|
||||
# license and grab content keys
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(session_drm)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
else:
|
||||
session_drm = None
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
return
|
||||
|
||||
unwanted_segments = [
|
||||
segment for segment in master.segments if callable(track.OnSegmentFilter) and track.OnSegmentFilter(segment)
|
||||
]
|
||||
|
||||
total_segments = len(master.segments) - len(unwanted_segments)
|
||||
progress(total=total_segments)
|
||||
|
||||
downloader = track.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]] = []
|
||||
segment_durations: list[int] = []
|
||||
|
||||
range_offset = 0
|
||||
for segment in master.segments:
|
||||
if segment in unwanted_segments:
|
||||
continue
|
||||
|
||||
segment_durations.append(int(segment.duration))
|
||||
|
||||
if segment.byterange:
|
||||
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
|
||||
range_offset = byte_range.split("-")[0]
|
||||
else:
|
||||
byte_range = None
|
||||
|
||||
urls.append(
|
||||
{
|
||||
"url": urljoin(segment.base_uri, segment.uri),
|
||||
"headers": {"Range": f"bytes={byte_range}"} if byte_range else {},
|
||||
}
|
||||
)
|
||||
|
||||
track.data["hls"]["segment_durations"] = segment_durations
|
||||
|
||||
segment_save_dir = save_dir / "segments"
|
||||
|
||||
skip_merge = False
|
||||
downloader_args = dict(
|
||||
urls=urls,
|
||||
output_dir=segment_save_dir,
|
||||
filename="{i:0%d}{ext}" % len(str(len(urls))),
|
||||
headers=session.headers,
|
||||
cookies=session.cookies,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
)
|
||||
|
||||
if downloader.__name__ == "n_m3u8dl_re":
|
||||
skip_merge = True
|
||||
downloader_args.update(
|
||||
{
|
||||
"output_dir": save_dir,
|
||||
"filename": track.id,
|
||||
"track": track,
|
||||
"content_keys": session_drm.content_keys if session_drm else None,
|
||||
}
|
||||
)
|
||||
|
||||
for status_update in downloader(**downloader_args):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if file_downloaded:
|
||||
events.emit(events.Types.SEGMENT_DOWNLOADED, track=track, segment=file_downloaded)
|
||||
else:
|
||||
downloaded = status_update.get("downloaded")
|
||||
if downloaded and downloaded.endswith("/s"):
|
||||
status_update["downloaded"] = f"HLS {downloaded}"
|
||||
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()
|
||||
|
||||
if not skip_merge:
|
||||
progress(total=total_segments, completed=0, downloaded="Merging")
|
||||
|
||||
name_len = len(str(total_segments))
|
||||
discon_i = 0
|
||||
range_offset = 0
|
||||
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
||||
if session_drm:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (None, session_drm)
|
||||
else:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
||||
|
||||
i = -1
|
||||
for real_i, segment in enumerate(master.segments):
|
||||
if segment not in unwanted_segments:
|
||||
i += 1
|
||||
|
||||
is_last_segment = (real_i + 1) == len(master.segments)
|
||||
|
||||
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
|
||||
"""
|
||||
Merge all files to a given path, optionally including map data.
|
||||
|
||||
Parameters:
|
||||
to: The output file with all merged data.
|
||||
via: List of files to merge, in sequence.
|
||||
delete: Delete the file once it's been merged.
|
||||
include_map_data: Whether to include the init map data.
|
||||
"""
|
||||
with open(to, "wb") as x:
|
||||
if include_map_data and map_data and map_data[1]:
|
||||
x.write(map_data[1])
|
||||
for file in via:
|
||||
x.write(file.read_bytes())
|
||||
x.flush()
|
||||
if delete:
|
||||
file.unlink()
|
||||
|
||||
def decrypt(include_this_segment: bool) -> Path:
|
||||
"""
|
||||
Decrypt all segments that uses the currently set DRM.
|
||||
|
||||
All segments that will be decrypted with this DRM will be merged together
|
||||
in sequence, prefixed with the init data (if any), and then deleted. Once
|
||||
merged they will be decrypted. The merged and decrypted file names state
|
||||
the range of segments that were used.
|
||||
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
|
||||
Returns the decrypted path.
|
||||
"""
|
||||
drm = encryption_data[1]
|
||||
first_segment_i = next(
|
||||
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))
|
||||
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)}"
|
||||
merged_path = (
|
||||
segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}"
|
||||
)
|
||||
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
|
||||
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if file.stem.isdigit() and first_segment_i <= int(file.stem) <= last_segment_i
|
||||
]
|
||||
if not files:
|
||||
raise ValueError(f"None of the segment files for {segment_range} exist...")
|
||||
elif len(files) != range_len:
|
||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||
|
||||
if isinstance(drm, Widevine):
|
||||
# with widevine we can merge all segments and decrypt once
|
||||
merge(to=merged_path, via=files, 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)
|
||||
|
||||
return decrypted_path
|
||||
|
||||
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
|
||||
"""
|
||||
Merge all segments of the discontinuity.
|
||||
|
||||
All segment files for this discontinuity must already be downloaded and
|
||||
already decrypted (if it needs to be decrypted).
|
||||
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
include_map_data: Whether to prepend the init map data before the
|
||||
segment files when merging.
|
||||
"""
|
||||
last_segment_i = max(0, i - int(not include_this_segment))
|
||||
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if int(file.stem.replace("_decrypted", "").split("-")[-1]) <= last_segment_i
|
||||
]
|
||||
if files:
|
||||
to_dir = segment_save_dir.parent
|
||||
to_path = to_dir / f"{str(discon_i).zfill(name_len)}{files[-1].suffix}"
|
||||
merge(to=to_path, via=files, delete=True, include_map_data=include_map_data)
|
||||
|
||||
if segment not in unwanted_segments:
|
||||
if isinstance(track, Subtitle):
|
||||
segment_file_ext = get_extension(segment.uri)
|
||||
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
|
||||
segment_data = try_ensure_utf8(segment_file_path.read_bytes())
|
||||
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
|
||||
segment_data = (
|
||||
segment_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
segment_file_path.write_bytes(segment_data)
|
||||
|
||||
if segment.discontinuity and i != 0:
|
||||
if encryption_data:
|
||||
decrypt(include_this_segment=False)
|
||||
merge_discontinuity(
|
||||
include_this_segment=False, include_map_data=not encryption_data or not encryption_data[1]
|
||||
)
|
||||
|
||||
discon_i += 1
|
||||
range_offset = 0 # TODO: Should this be reset or not?
|
||||
map_data = None
|
||||
if encryption_data:
|
||||
encryption_data = (encryption_data[0], encryption_data[1])
|
||||
|
||||
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 = {}
|
||||
|
||||
# Handle both session types for init section request
|
||||
res = session.get(
|
||||
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
|
||||
headers=init_range_header,
|
||||
)
|
||||
|
||||
# Check response based on session type
|
||||
if isinstance(res, requests.Response):
|
||||
res.raise_for_status()
|
||||
init_content = res.content
|
||||
elif isinstance(res, httpx.Response):
|
||||
if res.status_code >= 400:
|
||||
raise requests.HTTPError(f"HTTP Error: {res.status_code}", response=res)
|
||||
init_content = res.content
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected response to be requests.Response or httpx.Response, not {type(res)}"
|
||||
)
|
||||
|
||||
map_data = (segment.init_section, init_content)
|
||||
|
||||
segment_keys = getattr(segment, "keys", None)
|
||||
if 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:
|
||||
decrypt(include_this_segment=False)
|
||||
|
||||
if key is None:
|
||||
encryption_data = None
|
||||
elif not encryption_data or encryption_data[0] != key:
|
||||
drm = HLS.get_drm(key, session)
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
try:
|
||||
if map_data:
|
||||
track_kid = track.get_key_id(map_data[1])
|
||||
else:
|
||||
track_kid = None
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
encryption_data = (key, drm)
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
continue
|
||||
|
||||
if is_last_segment:
|
||||
# required as it won't end with EXT-X-DISCONTINUITY nor a new key
|
||||
if encryption_data:
|
||||
decrypt(include_this_segment=True)
|
||||
merge_discontinuity(
|
||||
include_this_segment=True, include_map_data=not encryption_data or not encryption_data[1]
|
||||
)
|
||||
|
||||
progress(advance=1)
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
return
|
||||
|
||||
if segment_save_dir.exists():
|
||||
segment_save_dir.rmdir()
|
||||
|
||||
# finally merge all the discontinuity save files together to the final path
|
||||
segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()]
|
||||
if len(segments_to_merge) == 1:
|
||||
shutil.move(segments_to_merge[0], save_path)
|
||||
else:
|
||||
progress(downloaded="Merging")
|
||||
if isinstance(track, (Video, Audio)):
|
||||
HLS.merge_segments(segments=segments_to_merge, save_path=save_path)
|
||||
else:
|
||||
with open(save_path, "wb") as f:
|
||||
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")
|
||||
|
||||
track.path = save_path
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=track)
|
||||
|
||||
@staticmethod
|
||||
def merge_segments(segments: list[Path], save_path: Path) -> int:
|
||||
"""
|
||||
Concatenate Segments by first demuxing with FFmpeg.
|
||||
|
||||
Returns the file size of the merged file.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
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.write_text("\n".join([f"file '{segment}'" for segment in segments]))
|
||||
|
||||
subprocess.check_call(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"panic",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
demuxer_file,
|
||||
"-map",
|
||||
"0",
|
||||
"-c",
|
||||
"copy",
|
||||
save_path,
|
||||
]
|
||||
)
|
||||
demuxer_file.unlink()
|
||||
|
||||
for segment in segments:
|
||||
segment.unlink()
|
||||
|
||||
return save_path.stat().st_size
|
||||
|
||||
@staticmethod
|
||||
def parse_session_data_keys(
|
||||
manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None
|
||||
) -> list[m3u8.model.Key]:
|
||||
"""Parse `com.apple.hls.keys` session data and return Key objects."""
|
||||
keys: list[m3u8.model.Key] = []
|
||||
|
||||
for data in getattr(manifest, "session_data", []) or []:
|
||||
if getattr(data, "data_id", None) != "com.apple.hls.keys":
|
||||
continue
|
||||
|
||||
value = getattr(data, "value", None)
|
||||
if not value and data.uri:
|
||||
if not session:
|
||||
session = Session()
|
||||
res = session.get(urljoin(manifest.base_uri or "", data.uri))
|
||||
value = res.text
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(value).decode()
|
||||
except Exception:
|
||||
decoded = value
|
||||
|
||||
try:
|
||||
items = json.loads(decoded)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for item in items if isinstance(items, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = m3u8.model.Key(
|
||||
method=item.get("method"),
|
||||
base_uri=manifest.base_uri or "",
|
||||
uri=item.get("uri"),
|
||||
keyformat=item.get("keyformat"),
|
||||
keyformatversions=",".join(item.get("keyformatversion") or item.get("keyformatversions") or []),
|
||||
)
|
||||
if key.method in {"AES-128", "ISO-23001-7"} or (
|
||||
key.keyformat
|
||||
and key.keyformat.lower()
|
||||
in {
|
||||
WidevineCdm.urn,
|
||||
PlayReadyCdm,
|
||||
"com.microsoft.playready",
|
||||
}
|
||||
):
|
||||
keys.append(key)
|
||||
|
||||
return keys
|
||||
|
||||
@staticmethod
|
||||
def get_supported_key(keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]]) -> Optional[m3u8.Key]:
|
||||
"""
|
||||
Get a support Key System from a list of Key systems.
|
||||
|
||||
Note that the key systems are chosen in an opinionated order.
|
||||
|
||||
Returns None if one of the key systems is method=NONE, which means all segments
|
||||
from hence forth should be treated as plain text until another key system is
|
||||
encountered, unless it's also method=NONE.
|
||||
|
||||
Raises NotImplementedError if none of the key systems are supported.
|
||||
"""
|
||||
if any(key.method == "NONE" for key in keys):
|
||||
return None
|
||||
|
||||
unsupported_systems = []
|
||||
for key in keys:
|
||||
if not key:
|
||||
continue
|
||||
# TODO: Add a way to specify which supported key system to use
|
||||
# TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey'
|
||||
elif key.method == "AES-128":
|
||||
return key
|
||||
elif key.method == "ISO-23001-7":
|
||||
return key
|
||||
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
|
||||
return key
|
||||
elif key.keyformat and (
|
||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
||||
):
|
||||
return key
|
||||
else:
|
||||
unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else ""))
|
||||
else:
|
||||
raise NotImplementedError(f"None of the key systems are supported: {', '.join(unsupported_systems)}")
|
||||
|
||||
@staticmethod
|
||||
def get_drm(
|
||||
key: Union[m3u8.model.SessionKey, m3u8.model.Key], session: Optional[Union[Session, httpx.Client]] = None
|
||||
) -> DRM_T:
|
||||
"""
|
||||
Convert HLS EXT-X-KEY data to an initialized DRM object.
|
||||
|
||||
Parameters:
|
||||
key: m3u8 key system (EXT-X-KEY) object.
|
||||
session: Optional session used to request AES-128 URIs.
|
||||
Useful to set headers, proxies, cookies, and so forth.
|
||||
|
||||
Raises a NotImplementedError if the key system is not supported.
|
||||
"""
|
||||
if not isinstance(session, (Session, httpx.Client, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {type(session)}")
|
||||
if not session:
|
||||
session = Session()
|
||||
|
||||
# TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey'
|
||||
if key.method == "AES-128":
|
||||
drm = ClearKey.from_m3u_key(key, session)
|
||||
elif key.method == "ISO-23001-7":
|
||||
drm = Widevine(pssh=WV_PSSH.new(key_ids=[key.uri.split(",")[-1]], system_id=WV_PSSH.SystemId.Widevine))
|
||||
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
|
||||
drm = Widevine(
|
||||
pssh=WV_PSSH(key.uri.split(",")[-1]),
|
||||
**key._extra_params, # noqa
|
||||
)
|
||||
elif key.keyformat and (
|
||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
||||
):
|
||||
drm = PlayReady(
|
||||
pssh=PR_PSSH(key.uri.split(",")[-1]),
|
||||
pssh_b64=key.uri.split(",")[-1],
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"The key system is not supported: {key}")
|
||||
|
||||
return drm
|
||||
|
||||
@staticmethod
|
||||
def get_all_drm(
|
||||
keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]], proxy: Optional[str] = None
|
||||
) -> list[DRM_T]:
|
||||
"""
|
||||
Convert HLS EXT-X-KEY data to initialized DRM objects.
|
||||
|
||||
Parameters:
|
||||
keys: m3u8 key system (EXT-X-KEY) objects.
|
||||
proxy: Optional proxy string used for requesting AES-128 URIs.
|
||||
|
||||
Raises a NotImplementedError if none of the key systems are supported.
|
||||
"""
|
||||
unsupported_keys: list[m3u8.Key] = []
|
||||
drm_objects: list[DRM_T] = []
|
||||
|
||||
if any(key.method == "NONE" for key in keys):
|
||||
return []
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
drm = HLS.get_drm(key, proxy)
|
||||
drm_objects.append(drm)
|
||||
except NotImplementedError:
|
||||
unsupported_keys.append(key)
|
||||
|
||||
if not drm_objects and unsupported_keys:
|
||||
logging.debug(
|
||||
"Ignoring unsupported key systems: %s",
|
||||
", ".join([str(k.keyformat or k.method) for k in unsupported_keys]),
|
||||
)
|
||||
return []
|
||||
|
||||
return drm_objects
|
||||
|
||||
@staticmethod
|
||||
def calculate_byte_range(m3u_range: str, fallback_offset: int = 0) -> str:
|
||||
"""
|
||||
Convert a HLS EXT-X-BYTERANGE value to a more traditional range value.
|
||||
E.g., '1433@0' -> '0-1432', '357392@1433' -> '1433-358824'.
|
||||
"""
|
||||
parts = [int(x) for x in m3u_range.split("@")]
|
||||
if len(parts) != 2:
|
||||
parts.append(fallback_offset)
|
||||
length, offset = parts
|
||||
return f"{offset}-{offset + length - 1}"
|
||||
|
||||
|
||||
__all__ = ("HLS",)
|
||||
335
unshackle/core/manifests/ism.py
Normal file
335
unshackle/core/manifests/ism.py
Normal file
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
import requests
|
||||
from langcodes import Language, tag_is_valid
|
||||
from lxml.etree import Element
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.pssh import PSSH
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.tracks import Audio, Subtitle, Track, Tracks, Video
|
||||
from unshackle.core.utilities import try_ensure_utf8
|
||||
from unshackle.core.utils.xml import load_xml
|
||||
|
||||
|
||||
class ISM:
|
||||
def __init__(self, manifest: Element, url: str) -> None:
|
||||
if manifest.tag != "SmoothStreamingMedia":
|
||||
raise TypeError(f"Expected 'SmoothStreamingMedia' document, got '{manifest.tag}'")
|
||||
if not url:
|
||||
raise requests.URLRequired("ISM manifest URL must be provided for relative paths")
|
||||
self.manifest = manifest
|
||||
self.url = url
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Session] = None, **kwargs: Any) -> "ISM":
|
||||
if not url:
|
||||
raise requests.URLRequired("ISM manifest URL must be provided")
|
||||
if not session:
|
||||
session = Session()
|
||||
res = session.get(url, **kwargs)
|
||||
if res.url != url:
|
||||
url = res.url
|
||||
res.raise_for_status()
|
||||
return cls(load_xml(res.content), url)
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str, url: str) -> "ISM":
|
||||
if not text:
|
||||
raise ValueError("ISM manifest text must be provided")
|
||||
if not url:
|
||||
raise requests.URLRequired("ISM manifest URL must be provided for relative paths")
|
||||
return cls(load_xml(text), url)
|
||||
|
||||
@staticmethod
|
||||
def _get_drm(headers: list[Element]) -> list[DRM_T]:
|
||||
drm: list[DRM_T] = []
|
||||
for header in headers:
|
||||
system_id = (header.get("SystemID") or header.get("SystemId") or "").lower()
|
||||
data = "".join(header.itertext()).strip()
|
||||
if not data:
|
||||
continue
|
||||
if system_id == "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":
|
||||
try:
|
||||
pssh = PSSH(base64.b64decode(data))
|
||||
except Exception:
|
||||
continue
|
||||
kid = next(iter(pssh.key_ids), None)
|
||||
drm.append(Widevine(pssh=pssh, kid=kid))
|
||||
elif system_id == "9a04f079-9840-4286-ab92-e65be0885f95":
|
||||
try:
|
||||
pr_pssh = PR_PSSH(data)
|
||||
except Exception:
|
||||
continue
|
||||
drm.append(PlayReady(pssh=pr_pssh, pssh_b64=data))
|
||||
return drm
|
||||
|
||||
def to_tracks(self, language: Optional[Union[str, Language]] = None) -> Tracks:
|
||||
tracks = Tracks()
|
||||
base_url = self.url
|
||||
duration = int(self.manifest.get("Duration") or 0)
|
||||
drm = self._get_drm(self.manifest.xpath(".//ProtectionHeader"))
|
||||
|
||||
for stream_index in self.manifest.findall("StreamIndex"):
|
||||
content_type = stream_index.get("Type")
|
||||
if not content_type:
|
||||
raise ValueError("No content type value could be found")
|
||||
for ql in stream_index.findall("QualityLevel"):
|
||||
codec = ql.get("FourCC")
|
||||
if codec == "TTML":
|
||||
codec = "STPP"
|
||||
track_lang = None
|
||||
lang = (stream_index.get("Language") or "").strip()
|
||||
if lang and tag_is_valid(lang) and not lang.startswith("und"):
|
||||
track_lang = Language.get(lang)
|
||||
|
||||
track_urls: list[str] = []
|
||||
fragment_time = 0
|
||||
fragments = stream_index.findall("c")
|
||||
# Some manifests omit the first fragment in the <c> list but
|
||||
# still expect a request for start time 0 which contains the
|
||||
# initialization segment. If the first declared fragment is not
|
||||
# at time 0, prepend the missing initialization URL.
|
||||
if fragments:
|
||||
first_time = int(fragments[0].get("t") or 0)
|
||||
if first_time != 0:
|
||||
track_urls.append(
|
||||
urllib.parse.urljoin(
|
||||
base_url,
|
||||
stream_index.get("Url").format_map(
|
||||
{
|
||||
"bitrate": ql.get("Bitrate"),
|
||||
"start time": "0",
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for idx, frag in enumerate(fragments):
|
||||
fragment_time = int(frag.get("t", fragment_time))
|
||||
repeat = int(frag.get("r", 1))
|
||||
duration_frag = int(frag.get("d") or 0)
|
||||
if not duration_frag:
|
||||
try:
|
||||
next_time = int(fragments[idx + 1].get("t"))
|
||||
except (IndexError, AttributeError):
|
||||
next_time = duration
|
||||
duration_frag = (next_time - fragment_time) / repeat
|
||||
for _ in range(repeat):
|
||||
track_urls.append(
|
||||
urllib.parse.urljoin(
|
||||
base_url,
|
||||
stream_index.get("Url").format_map(
|
||||
{
|
||||
"bitrate": ql.get("Bitrate"),
|
||||
"start time": str(fragment_time),
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
fragment_time += duration_frag
|
||||
|
||||
track_id = hashlib.md5(
|
||||
f"{codec}-{track_lang}-{ql.get('Bitrate') or 0}-{ql.get('Index') or 0}".encode()
|
||||
).hexdigest()
|
||||
|
||||
data = {
|
||||
"ism": {
|
||||
"manifest": self.manifest,
|
||||
"stream_index": stream_index,
|
||||
"quality_level": ql,
|
||||
"segments": track_urls,
|
||||
}
|
||||
}
|
||||
|
||||
if content_type == "video":
|
||||
try:
|
||||
vcodec = Video.Codec.from_mime(codec) if codec else None
|
||||
except ValueError:
|
||||
vcodec = None
|
||||
tracks.add(
|
||||
Video(
|
||||
id_=track_id,
|
||||
url=self.url,
|
||||
codec=vcodec,
|
||||
language=track_lang or language,
|
||||
is_original_lang=bool(language and track_lang and str(track_lang) == str(language)),
|
||||
bitrate=ql.get("Bitrate"),
|
||||
width=int(ql.get("MaxWidth") or 0) or int(stream_index.get("MaxWidth") or 0),
|
||||
height=int(ql.get("MaxHeight") or 0) or int(stream_index.get("MaxHeight") or 0),
|
||||
descriptor=Video.Descriptor.ISM,
|
||||
drm=drm,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
elif content_type == "audio":
|
||||
try:
|
||||
acodec = Audio.Codec.from_mime(codec) if codec else None
|
||||
except ValueError:
|
||||
acodec = None
|
||||
tracks.add(
|
||||
Audio(
|
||||
id_=track_id,
|
||||
url=self.url,
|
||||
codec=acodec,
|
||||
language=track_lang or language,
|
||||
is_original_lang=bool(language and track_lang and str(track_lang) == str(language)),
|
||||
bitrate=ql.get("Bitrate"),
|
||||
channels=ql.get("Channels"),
|
||||
descriptor=Track.Descriptor.ISM,
|
||||
drm=drm,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
scodec = Subtitle.Codec.from_mime(codec) if codec else None
|
||||
except ValueError:
|
||||
scodec = None
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=track_id,
|
||||
url=self.url,
|
||||
codec=scodec,
|
||||
language=track_lang or language,
|
||||
is_original_lang=bool(language and track_lang and str(track_lang) == str(language)),
|
||||
descriptor=Track.Descriptor.ISM,
|
||||
drm=drm,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
return tracks
|
||||
|
||||
@staticmethod
|
||||
def download_track(
|
||||
track: AnyTrack,
|
||||
save_path: Path,
|
||||
save_dir: Path,
|
||||
progress: partial,
|
||||
session: Optional[Session] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
license_widevine: Optional[Callable] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
) -> None:
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, Session):
|
||||
raise TypeError(f"Expected session to be a {Session}, not {session!r}")
|
||||
|
||||
if proxy:
|
||||
session.proxies.update({"all": proxy})
|
||||
|
||||
segments: list[str] = track.data["ism"]["segments"]
|
||||
|
||||
session_drm = None
|
||||
if track.drm:
|
||||
# Mirror HLS.download_track: pick the DRM matching the provided CDM
|
||||
# (or the first available) and license it if supported.
|
||||
session_drm = track.get_drm_for_cdm(cdm)
|
||||
if isinstance(session_drm, (Widevine, PlayReady)):
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(session_drm)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception:
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
return
|
||||
|
||||
progress(total=len(segments))
|
||||
|
||||
downloader = track.downloader
|
||||
skip_merge = False
|
||||
downloader_args = dict(
|
||||
urls=[{"url": url} for url in segments],
|
||||
output_dir=save_dir,
|
||||
filename="{i:0%d}.mp4" % len(str(len(segments))),
|
||||
headers=session.headers,
|
||||
cookies=session.cookies,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
)
|
||||
|
||||
if downloader.__name__ == "n_m3u8dl_re":
|
||||
skip_merge = True
|
||||
downloader_args.update(
|
||||
{
|
||||
"filename": track.id,
|
||||
"track": track,
|
||||
"content_keys": session_drm.content_keys if session_drm else None,
|
||||
}
|
||||
)
|
||||
|
||||
for status_update in downloader(**downloader_args):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if file_downloaded:
|
||||
events.emit(events.Types.SEGMENT_DOWNLOADED, track=track, segment=file_downloaded)
|
||||
else:
|
||||
downloaded = status_update.get("downloaded")
|
||||
if downloaded and downloaded.endswith("/s"):
|
||||
status_update["downloaded"] = f"ISM {downloaded}"
|
||||
progress(**status_update)
|
||||
|
||||
for control_file in save_dir.glob("*.aria2__temp"):
|
||||
control_file.unlink()
|
||||
|
||||
segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()]
|
||||
|
||||
if skip_merge:
|
||||
shutil.move(segments_to_merge[0], save_path)
|
||||
else:
|
||||
with open(save_path, "wb") as f:
|
||||
for segment_file in segments_to_merge:
|
||||
segment_data = segment_file.read_bytes()
|
||||
if (
|
||||
not session_drm
|
||||
and isinstance(track, Subtitle)
|
||||
and track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML)
|
||||
):
|
||||
segment_data = try_ensure_utf8(segment_data)
|
||||
segment_data = (
|
||||
segment_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
f.write(segment_data)
|
||||
f.flush()
|
||||
segment_file.unlink()
|
||||
progress(advance=1)
|
||||
|
||||
track.path = save_path
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=track)
|
||||
|
||||
if not skip_merge and session_drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=100)
|
||||
session_drm.decrypt(save_path)
|
||||
track.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=session_drm, segment=None)
|
||||
progress(downloaded="Decrypting", advance=100)
|
||||
|
||||
save_dir.rmdir()
|
||||
progress(downloaded="Downloaded")
|
||||
|
||||
|
||||
__all__ = ("ISM",)
|
||||
76
unshackle/core/manifests/m3u8.py
Normal file
76
unshackle/core/manifests/m3u8.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Utility functions for parsing M3U8 playlists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
import httpx
|
||||
import m3u8
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH as WV_PSSH
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core.drm import PlayReady, Widevine
|
||||
from unshackle.core.manifests.hls import HLS
|
||||
from unshackle.core.tracks import Tracks
|
||||
|
||||
|
||||
def parse(
|
||||
master: m3u8.M3U8,
|
||||
language: str,
|
||||
*,
|
||||
session: Optional[Union[Session, httpx.Client]] = None,
|
||||
) -> Tracks:
|
||||
"""Parse a variant playlist to ``Tracks`` with DRM information."""
|
||||
tracks = HLS(master, session=session).to_tracks(language)
|
||||
|
||||
need_wv = not any(isinstance(d, Widevine) for t in tracks for d in (t.drm or []))
|
||||
need_pr = not any(isinstance(d, PlayReady) for t in tracks for d in (t.drm or []))
|
||||
|
||||
if (need_wv or need_pr) and tracks.videos:
|
||||
if not session:
|
||||
session = Session()
|
||||
|
||||
session_keys = list(master.session_keys or [])
|
||||
session_keys.extend(HLS.parse_session_data_keys(master, session))
|
||||
|
||||
for drm_obj in HLS.get_all_drm(session_keys):
|
||||
if need_wv and isinstance(drm_obj, Widevine):
|
||||
for t in tracks.videos + tracks.audio:
|
||||
t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm_obj]
|
||||
need_wv = False
|
||||
elif need_pr and isinstance(drm_obj, PlayReady):
|
||||
for t in tracks.videos + tracks.audio:
|
||||
t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm_obj]
|
||||
need_pr = False
|
||||
if not need_wv and not need_pr:
|
||||
break
|
||||
|
||||
if (need_wv or need_pr) and tracks.videos:
|
||||
first_video = tracks.videos[0]
|
||||
playlist = m3u8.load(first_video.url)
|
||||
for key in playlist.keys or []:
|
||||
if not key or not key.keyformat:
|
||||
continue
|
||||
fmt = key.keyformat.lower()
|
||||
if need_wv and fmt == WidevineCdm.urn:
|
||||
pssh_b64 = key.uri.split(",")[-1]
|
||||
drm = Widevine(pssh=WV_PSSH(pssh_b64))
|
||||
for t in tracks.videos + tracks.audio:
|
||||
t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm]
|
||||
need_wv = False
|
||||
elif need_pr and (fmt == PlayReadyCdm or "com.microsoft.playready" in fmt):
|
||||
pssh_b64 = key.uri.split(",")[-1]
|
||||
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
||||
for t in tracks.videos + tracks.audio:
|
||||
t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm]
|
||||
need_pr = False
|
||||
if not need_wv and not need_pr:
|
||||
break
|
||||
|
||||
return tracks
|
||||
|
||||
|
||||
__all__ = ["parse"]
|
||||
5
unshackle/core/proxies/__init__.py
Normal file
5
unshackle/core/proxies/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .basic import Basic
|
||||
from .hola import Hola
|
||||
from .nordvpn import NordVPN
|
||||
|
||||
__all__ = ("Basic", "Hola", "NordVPN")
|
||||
54
unshackle/core/proxies/basic.py
Normal file
54
unshackle/core/proxies/basic.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import random
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
|
||||
from requests.utils import prepend_scheme_if_needed
|
||||
from urllib3.util import parse_url
|
||||
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class Basic(Proxy):
|
||||
def __init__(self, **countries: dict[str, Union[str, list[str]]]):
|
||||
"""Basic Proxy Service using Proxies specified in the config."""
|
||||
self.countries = {k.lower(): v for k, v in countries.items()}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
countries = len(self.countries)
|
||||
servers = len(self.countries.values())
|
||||
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})"
|
||||
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""Get a proxy URI from the config."""
|
||||
query = query.lower()
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
if isinstance(servers, str):
|
||||
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"} for "{country_code}"...'
|
||||
)
|
||||
else:
|
||||
proxy = random.choice(servers)
|
||||
|
||||
proxy = prepend_scheme_if_needed(proxy, "http")
|
||||
parsed_proxy = parse_url(proxy)
|
||||
if not parsed_proxy.host:
|
||||
raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.")
|
||||
|
||||
return proxy
|
||||
60
unshackle/core/proxies/hola.py
Normal file
60
unshackle/core/proxies/hola.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class Hola(Proxy):
|
||||
def __init__(self):
|
||||
"""
|
||||
Proxy Service using Hola's direct connections via the hola-proxy project.
|
||||
https://github.com/Snawoot/hola-proxy
|
||||
"""
|
||||
self.binary = binaries.HolaProxy
|
||||
if not self.binary:
|
||||
raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.")
|
||||
|
||||
self.countries = self.get_countries()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
countries = len(self.countries)
|
||||
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]}"
|
||||
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTP proxy URI for a Datacenter ('direct') or Residential ('lum') Hola server.
|
||||
|
||||
TODO: - Add ability to select 'lum' proxies (residential proxies).
|
||||
- Return and use Proxy Authorization
|
||||
"""
|
||||
query = query.lower()
|
||||
|
||||
p = subprocess.check_output(
|
||||
[self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT
|
||||
).decode()
|
||||
|
||||
if "Transaction error: temporary ban detected." in p:
|
||||
raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.")
|
||||
|
||||
username, password, proxy_authorization = re.search(
|
||||
r"Login: (.*)\nPassword: (.*)\nProxy-Authorization: (.*)", p
|
||||
).groups()
|
||||
|
||||
servers = re.findall(r"(zagent.*)", p)
|
||||
proxies = []
|
||||
for server in servers:
|
||||
host, ip_address, direct, peer, hola, trial, trial_peer, vendor = server.split(",")
|
||||
proxies.append(f"http://{username}:{password}@{ip_address}:{peer}")
|
||||
|
||||
proxy = random.choice(proxies)
|
||||
return proxy
|
||||
|
||||
def get_countries(self) -> list[dict[str, str]]:
|
||||
"""Get a list of available Countries."""
|
||||
p = subprocess.check_output([self.binary, "-list-countries"]).decode("utf8")
|
||||
|
||||
return [{code: name} for country in p.splitlines() for (code, name) in [country.split(" - ", maxsplit=1)]]
|
||||
128
unshackle/core/proxies/nordvpn.py
Normal file
128
unshackle/core/proxies/nordvpn.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class NordVPN(Proxy):
|
||||
def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None):
|
||||
"""
|
||||
Proxy Service using NordVPN Service Credentials.
|
||||
|
||||
A username and password must be provided. These are Service Credentials, not your Login Credentials.
|
||||
The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/
|
||||
"""
|
||||
if not username:
|
||||
raise ValueError("No Username was provided to the NordVPN Proxy Service.")
|
||||
if not password:
|
||||
raise ValueError("No Password was provided to the NordVPN Proxy Service.")
|
||||
if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username:
|
||||
raise ValueError(
|
||||
"The Username and Password must be NordVPN Service Credentials, not your Login Credentials. "
|
||||
"The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/"
|
||||
)
|
||||
|
||||
if server_map is not None and not isinstance(server_map, dict):
|
||||
raise TypeError(f"Expected server_map to be a dict mapping a region to a server ID, not '{server_map!r}'.")
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.server_map = server_map or {}
|
||||
|
||||
self.countries = self.get_countries()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
countries = len(self.countries)
|
||||
servers = sum(x["serverCount"] for x in self.countries)
|
||||
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})"
|
||||
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTP(SSL) proxy URI for a NordVPN server.
|
||||
|
||||
HTTP proxies under port 80 were disabled on the 15th of Feb, 2021:
|
||||
https://nordvpn.com/blog/removing-http-proxies
|
||||
"""
|
||||
query = query.lower()
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and nordvpn server id, e.g., us1, fr1234
|
||||
hostname = f"{query}.nordvpn.com"
|
||||
else:
|
||||
if query.isdigit():
|
||||
# country id
|
||||
country = self.get_country(by_id=int(query))
|
||||
elif re.match(r"^[a-z]+$", query):
|
||||
# country code
|
||||
country = self.get_country(by_code=query)
|
||||
else:
|
||||
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
||||
if not country:
|
||||
# NordVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["code"].lower())
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com"
|
||||
else:
|
||||
# get the recommended server ID
|
||||
recommended_servers = self.get_recommended_servers(country["id"])
|
||||
if not recommended_servers:
|
||||
raise ValueError(
|
||||
f"The NordVPN Country {query} currently has no recommended servers. "
|
||||
"Try again later. If the issue persists, double-check the query."
|
||||
)
|
||||
hostname = recommended_servers[0]["hostname"]
|
||||
|
||||
if hostname.startswith("gb"):
|
||||
# NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname
|
||||
hostname = f"gb{hostname[2:]}"
|
||||
|
||||
return f"https://{self.username}:{self.password}@{hostname}:89"
|
||||
|
||||
def get_country(self, by_id: Optional[int] = None, by_code: Optional[str] = None) -> Optional[dict]:
|
||||
"""Search for a Country and it's metadata."""
|
||||
if all(x is None for x in (by_id, by_code)):
|
||||
raise ValueError("At least one search query must be made.")
|
||||
|
||||
for country in self.countries:
|
||||
if all(
|
||||
[by_id is None or country["id"] == int(by_id), by_code is None or country["code"] == by_code.upper()]
|
||||
):
|
||||
return country
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_servers(country_id: int) -> list[dict]:
|
||||
"""
|
||||
Get the list of recommended Servers for a Country.
|
||||
|
||||
Note: There may not always be more than one recommended server.
|
||||
"""
|
||||
res = requests.get(
|
||||
url="https://api.nordvpn.com/v1/servers/recommendations", params={"filters[country_id]": country_id}
|
||||
)
|
||||
if not res.ok:
|
||||
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
|
||||
|
||||
try:
|
||||
return res.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Could not decode list of NordVPN countries, not JSON data.")
|
||||
|
||||
@staticmethod
|
||||
def get_countries() -> list[dict]:
|
||||
"""Get a list of available Countries and their metadata."""
|
||||
res = requests.get(
|
||||
url="https://api.nordvpn.com/v1/servers/countries",
|
||||
)
|
||||
if not res.ok:
|
||||
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
|
||||
|
||||
try:
|
||||
return res.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Could not decode list of NordVPN countries, not JSON data.")
|
||||
31
unshackle/core/proxies/proxy.py
Normal file
31
unshackle/core/proxies/proxy.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Proxy:
|
||||
@abstractmethod
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
The constructor initializes the Service using passed configuration data.
|
||||
|
||||
Any authorization or pre-fetching of data should be done here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string denoting a list of Countries and Servers (if possible)."""
|
||||
countries = ...
|
||||
servers = ...
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})"
|
||||
|
||||
@abstractmethod
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get a Proxy URI from the Proxy Service.
|
||||
|
||||
Only return None if the query was accepted, but no proxy could be returned.
|
||||
Otherwise, please use exceptions to denote any errors with the call or query.
|
||||
|
||||
The returned Proxy URI must be a string supported by Python-Requests:
|
||||
'{scheme}://[{user}:{pass}@]{host}:{port}'
|
||||
"""
|
||||
44
unshackle/core/search_result.py
Normal file
44
unshackle/core/search_result.py
Normal file
@@ -0,0 +1,44 @@
|
||||
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",)
|
||||
290
unshackle/core/service.py
Normal file
290
unshackle/core/service.py
Normal file
@@ -0,0 +1,290 @@
|
||||
import base64
|
||||
import logging
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections.abc import Generator
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
|
||||
from unshackle.core.cacher import Cacher
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.credential import Credential
|
||||
from unshackle.core.drm import DRM_T
|
||||
from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.titles import Title_T, Titles_T
|
||||
from unshackle.core.tracks import Chapters, Tracks
|
||||
from unshackle.core.utilities import get_ip_info
|
||||
|
||||
|
||||
class Service(metaclass=ABCMeta):
|
||||
"""The Service Base Class."""
|
||||
|
||||
# Abstract class variables
|
||||
ALIASES: tuple[str, ...] = () # list of aliases for the service; alternatives to the service tag.
|
||||
GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region.
|
||||
|
||||
def __init__(self, ctx: click.Context):
|
||||
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
|
||||
|
||||
self.config = ctx.obj.config
|
||||
|
||||
self.log = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
self.session = self.get_session()
|
||||
self.cache = Cacher(self.__class__.__name__)
|
||||
|
||||
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||
if ctx.parent:
|
||||
proxy = ctx.parent.params["proxy"]
|
||||
else:
|
||||
proxy = None
|
||||
|
||||
if not proxy:
|
||||
# don't override the explicit proxy set by the user, even if they may be geoblocked
|
||||
with console.status("Checking if current region is Geoblocked...", spinner="dots"):
|
||||
if self.GEOFENCE:
|
||||
# no explicit proxy, let's get one to GEOFENCE if needed
|
||||
current_region = get_ip_info(self.session)["country"].lower()
|
||||
if any(x.lower() == current_region for x in self.GEOFENCE):
|
||||
self.log.info("Service is not Geoblocked in your region")
|
||||
else:
|
||||
requested_proxy = self.GEOFENCE[0] # first is likely main region
|
||||
self.log.info(f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}")
|
||||
for proxy_provider in ctx.obj.proxy_providers:
|
||||
proxy = proxy_provider.get_proxy(requested_proxy)
|
||||
if proxy:
|
||||
self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}")
|
||||
break
|
||||
else:
|
||||
self.log.info("Service has no Geofence")
|
||||
|
||||
if proxy:
|
||||
self.session.proxies.update({"all": proxy})
|
||||
proxy_parse = urlparse(proxy)
|
||||
if proxy_parse.username and proxy_parse.password:
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Proxy-Authorization": base64.b64encode(
|
||||
f"{proxy_parse.username}:{proxy_parse.password}".encode("utf8")
|
||||
).decode()
|
||||
}
|
||||
)
|
||||
|
||||
# Optional Abstract functions
|
||||
# The following functions may be implemented by the Service.
|
||||
# Otherwise, the base service code (if any) of the function will be executed on call.
|
||||
# The functions will be executed in shown order.
|
||||
|
||||
@staticmethod
|
||||
def get_session() -> requests.Session:
|
||||
"""
|
||||
Creates a Python-requests Session, adds common headers
|
||||
from config, cookies, retry handler, and a proxy if available.
|
||||
:returns: Prepared Python-requests Session
|
||||
"""
|
||||
session = requests.Session()
|
||||
session.headers.update(config.headers)
|
||||
session.mount(
|
||||
"https://",
|
||||
HTTPAdapter(
|
||||
max_retries=Retry(total=15, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
||||
pool_block=True,
|
||||
),
|
||||
)
|
||||
session.mount("http://", session.adapters["https://"])
|
||||
return session
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
"""
|
||||
Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).
|
||||
|
||||
This is effectively a login() function. Any API calls or object initializations
|
||||
needing to be made, should be made here. This will be run before any of the
|
||||
following abstract functions.
|
||||
|
||||
You should avoid storing or using the Credential outside this function.
|
||||
Make any calls you need for any Cookies, Tokens, or such, then use those.
|
||||
|
||||
The Cookie jar should also not be stored outside this function. However, you may load
|
||||
the Cookie jar into the service session.
|
||||
"""
|
||||
if cookies is not None:
|
||||
if not isinstance(cookies, CookieJar):
|
||||
raise TypeError(f"Expected cookies to be a {CookieJar}, not {cookies!r}.")
|
||||
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
|
||||
) -> Union[bytes, str]:
|
||||
"""
|
||||
Get the Widevine Service Certificate used for Privacy Mode.
|
||||
|
||||
:param challenge: The service challenge, providing this to a License endpoint should return the
|
||||
privacy certificate that the service uses.
|
||||
:param title: The current `Title` from get_titles that is being executed. This is provided in
|
||||
case it has data needed to be used, e.g. for a HTTP request.
|
||||
:param track: The current `Track` needing decryption. Provided for same reason as `title`.
|
||||
:return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or
|
||||
Decode the data, return as is to reduce unnecessary computations.
|
||||
"""
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||
"""
|
||||
Get a Widevine License message by sending a License Request (challenge).
|
||||
|
||||
This License message contains the encrypted Content Decryption Keys and will be
|
||||
read by the Cdm and decrypted.
|
||||
|
||||
This is a very important request to get correct. A bad, unexpected, or missing
|
||||
value in the request can cause your key to be detected and promptly banned,
|
||||
revoked, disabled, or downgraded.
|
||||
|
||||
:param challenge: The license challenge from the Widevine CDM.
|
||||
:param title: The current `Title` from get_titles that is being executed. This is provided in
|
||||
case it has data needed to be used, e.g. for a HTTP request.
|
||||
:param track: The current `Track` needing decryption. Provided for same reason as `title`.
|
||||
:return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
|
||||
Decode the data, return as is to reduce unnecessary computations.
|
||||
"""
|
||||
|
||||
# Required Abstract functions
|
||||
# The following functions *must* be implemented by the Service.
|
||||
# The functions will be executed in shown order.
|
||||
|
||||
@abstractmethod
|
||||
def get_titles(self) -> Titles_T:
|
||||
"""
|
||||
Get Titles for the provided title ID.
|
||||
|
||||
Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively.
|
||||
The returned data must be for the given title ID, or a spawn of the title ID.
|
||||
|
||||
At least one object is expected to be returned, or it will presume an invalid Title ID was
|
||||
provided.
|
||||
|
||||
You can use the `data` dictionary class instance attribute of each Title to store data you may need later on.
|
||||
This can be useful to store information on each title that will be required like any sub-asset IDs, or such.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
"""
|
||||
Get Track objects of the Title.
|
||||
|
||||
Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters.
|
||||
Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.
|
||||
|
||||
Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or
|
||||
a Subtitle file.
|
||||
|
||||
While one Track should only hold information for one stream/downloadable, try to get as many
|
||||
unique Track objects per stream type so Stream selection by the root code can give you more
|
||||
options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.
|
||||
|
||||
No decision making or filtering of which Tracks get returned should happen here. It can be
|
||||
considered an error to filter for e.g. resolution, codec, and such. All filtering based on
|
||||
arguments will be done by the root code automatically when needed.
|
||||
|
||||
Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System
|
||||
via its `drm` property.
|
||||
|
||||
If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide
|
||||
it to the Track's `kid` variable as it will speed up the decryption process later on. It may
|
||||
or may not be needed, that depends on the service. Generally if you can provide it, without
|
||||
downloading any of the Track's stream data, then do.
|
||||
|
||||
:param title: The current `Title` from get_titles that is being executed.
|
||||
:return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
"""
|
||||
Get Chapters for the Title.
|
||||
|
||||
Parameters:
|
||||
title: The current Title from `get_titles` that is being processed.
|
||||
|
||||
You must return a Chapters object containing 0 or more Chapter objects.
|
||||
|
||||
You do not need to set a Chapter number or sort/order the chapters in any way as
|
||||
the Chapters class automatically handles all of that for you. If there's no
|
||||
descriptive name for a Chapter then do not set a name at all.
|
||||
|
||||
You must not set Chapter names to "Chapter {n}" or such. If you (or the user)
|
||||
wants "Chapter {n}" style Chapter names (or similar) then they can use the config
|
||||
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",)
|
||||
83
unshackle/core/services.py
Normal file
83
unshackle/core/services.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.utilities import import_module_by_path
|
||||
|
||||
_SERVICES = sorted((path for path in config.directories.services.glob("*/__init__.py")), key=lambda x: x.parent.stem)
|
||||
|
||||
_MODULES = {path.parent.stem: getattr(import_module_by_path(path), path.parent.stem) for path in _SERVICES}
|
||||
|
||||
_ALIASES = {tag: getattr(module, "ALIASES") for tag, module in _MODULES.items()}
|
||||
|
||||
|
||||
class Services(click.MultiCommand):
|
||||
"""Lazy-loaded command group of project services."""
|
||||
|
||||
# Click-specific methods
|
||||
|
||||
def list_commands(self, ctx: click.Context) -> list[str]:
|
||||
"""Returns a list of all available Services as command names for Click."""
|
||||
return Services.get_tags()
|
||||
|
||||
def get_command(self, ctx: click.Context, name: str) -> click.Command:
|
||||
"""Load the Service and return the Click CLI method."""
|
||||
tag = Services.get_tag(name)
|
||||
try:
|
||||
service = Services.load(tag)
|
||||
except KeyError as e:
|
||||
available_services = self.list_commands(ctx)
|
||||
if not available_services:
|
||||
raise click.ClickException(
|
||||
f"There are no Services added yet, therefore the '{name}' Service could not be found."
|
||||
)
|
||||
raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}")
|
||||
|
||||
if hasattr(service, "cli"):
|
||||
return service.cli
|
||||
|
||||
raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.")
|
||||
|
||||
# Methods intended to be used anywhere
|
||||
|
||||
@staticmethod
|
||||
def get_tags() -> list[str]:
|
||||
"""Returns a list of service tags from all available Services."""
|
||||
return [x.parent.stem for x in _SERVICES]
|
||||
|
||||
@staticmethod
|
||||
def get_path(name: str) -> Path:
|
||||
"""Get the directory path of a command."""
|
||||
tag = Services.get_tag(name)
|
||||
for service in _SERVICES:
|
||||
if service.parent.stem == tag:
|
||||
return service.parent
|
||||
raise KeyError(f"There is no Service added by the Tag '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def get_tag(value: str) -> str:
|
||||
"""
|
||||
Get the Service Tag (e.g. DSNP, not DisneyPlus/Disney+, etc.) by an Alias.
|
||||
Input value can be of any case-sensitivity.
|
||||
Original input value is returned if it did not match a service tag.
|
||||
"""
|
||||
original_value = value
|
||||
value = value.lower()
|
||||
for path in _SERVICES:
|
||||
tag = path.parent.stem
|
||||
if value in (tag.lower(), *_ALIASES.get(tag, [])):
|
||||
return tag
|
||||
return original_value
|
||||
|
||||
@staticmethod
|
||||
def load(tag: str) -> Service:
|
||||
"""Load a Service module by Service tag."""
|
||||
module = _MODULES.get(tag)
|
||||
if not module:
|
||||
raise KeyError(f"There is no Service added by the Tag '{tag}'")
|
||||
return module
|
||||
|
||||
|
||||
__all__ = ("Services",)
|
||||
11
unshackle/core/titles/__init__.py
Normal file
11
unshackle/core/titles/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from typing import Union
|
||||
|
||||
from .episode import Episode, Series
|
||||
from .movie import Movie, Movies
|
||||
from .song import Album, Song
|
||||
|
||||
Title_T = Union[Movie, Episode, Song]
|
||||
Titles_T = Union[Movies, Series, Album]
|
||||
|
||||
|
||||
__all__ = ("Episode", "Series", "Movie", "Movies", "Album", "Song", "Title_T", "Titles_T")
|
||||
209
unshackle/core/titles/episode.py
Normal file
209
unshackle/core/titles/episode.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import re
|
||||
from abc import ABC
|
||||
from collections import Counter
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
from pymediainfo import MediaInfo
|
||||
from rich.tree import Tree
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
|
||||
from unshackle.core.titles.title import Title
|
||||
from unshackle.core.utilities import sanitize_filename
|
||||
|
||||
|
||||
class Episode(Title):
|
||||
def __init__(
|
||||
self,
|
||||
id_: Any,
|
||||
service: type,
|
||||
title: str,
|
||||
season: Union[int, str],
|
||||
number: Union[int, str],
|
||||
name: Optional[str] = None,
|
||||
year: Optional[Union[int, str]] = None,
|
||||
language: Optional[Union[str, Language]] = None,
|
||||
data: Optional[Any] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(id_, service, language, data)
|
||||
|
||||
if not title:
|
||||
raise ValueError("Episode title must be provided")
|
||||
if not isinstance(title, str):
|
||||
raise TypeError(f"Expected title to be a str, not {title!r}")
|
||||
|
||||
if season != 0 and not season:
|
||||
raise ValueError("Episode season must be provided")
|
||||
if isinstance(season, str) and season.isdigit():
|
||||
season = int(season)
|
||||
elif not isinstance(season, int):
|
||||
raise TypeError(f"Expected season to be an int, not {season!r}")
|
||||
|
||||
if number != 0 and not number:
|
||||
raise ValueError("Episode number must be provided")
|
||||
if isinstance(number, str) and number.isdigit():
|
||||
number = int(number)
|
||||
elif not isinstance(number, int):
|
||||
raise TypeError(f"Expected number to be an int, not {number!r}")
|
||||
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise TypeError(f"Expected name to be a str, not {name!r}")
|
||||
|
||||
if year is not None:
|
||||
if isinstance(year, str) and year.isdigit():
|
||||
year = int(year)
|
||||
elif not isinstance(year, int):
|
||||
raise TypeError(f"Expected year to be an int, not {year!r}")
|
||||
|
||||
title = title.strip()
|
||||
|
||||
if name is not None:
|
||||
name = name.strip()
|
||||
# ignore episode names that are the episode number or title name
|
||||
if re.match(r"Episode ?#?\d+", name, re.IGNORECASE):
|
||||
name = None
|
||||
elif name.lower() == title.lower():
|
||||
name = None
|
||||
|
||||
if year is not None and year <= 0:
|
||||
raise ValueError(f"Episode year cannot be {year}")
|
||||
|
||||
self.title = title
|
||||
self.season = season
|
||||
self.number = number
|
||||
self.name = name
|
||||
self.year = year
|
||||
self.description = description
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{title}{year} S{season:02}E{number:02} {name}".format(
|
||||
title=self.title,
|
||||
year=f" {self.year}" if self.year else "",
|
||||
season=self.season,
|
||||
number=self.number,
|
||||
name=self.name or "",
|
||||
).strip()
|
||||
|
||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||
primary_video_track = next(iter(media_info.video_tracks), None)
|
||||
primary_audio_track = next(iter(media_info.audio_tracks), None)
|
||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||
|
||||
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
|
||||
if folder:
|
||||
name = f"{self.title}"
|
||||
if self.year:
|
||||
name += f" {self.year}"
|
||||
name += f" S{self.season:02}"
|
||||
else:
|
||||
name = "{title}{year} S{season:02}E{number:02} {name}".format(
|
||||
title=self.title.replace("$", "S"), # e.g., Arli$$
|
||||
year=f" {self.year}" if self.year else "",
|
||||
season=self.season,
|
||||
number=self.number,
|
||||
name=self.name or "",
|
||||
).strip()
|
||||
|
||||
# MULTi
|
||||
if unique_audio_languages > 1:
|
||||
name += " MULTi"
|
||||
|
||||
# Resolution
|
||||
if primary_video_track:
|
||||
resolution = primary_video_track.height
|
||||
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
|
||||
if len(aspect_ratio) == 1:
|
||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||
aspect_ratio.append(1)
|
||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||
# otherwise the track's height value is fine.
|
||||
# We are assuming this title is some weird aspect ratio so most
|
||||
# likely a movie or HD source, so it's most likely widescreen so
|
||||
# 16:9 canvas makes the most sense.
|
||||
resolution = int(primary_video_track.width * (9 / 16))
|
||||
name += f" {resolution}p"
|
||||
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
|
||||
# Audio Codec + Channels (+ feature)
|
||||
if primary_audio_track:
|
||||
codec = primary_audio_track.format
|
||||
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
||||
if channel_layout:
|
||||
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
|
||||
else:
|
||||
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||
channels = float(channel_count)
|
||||
|
||||
features = primary_audio_track.format_additionalfeatures or ""
|
||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||
if "JOC" in features or primary_audio_track.joc:
|
||||
name += " Atmos"
|
||||
|
||||
# Video (dynamic range + hfr +) Codec
|
||||
if primary_video_track:
|
||||
codec = primary_video_track.format
|
||||
hdr_format = primary_video_track.hdr_format_commercial
|
||||
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
|
||||
frame_rate = float(primary_video_track.frame_rate)
|
||||
if hdr_format:
|
||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||
elif trc and "HLG" in trc:
|
||||
name += " HLG"
|
||||
if frame_rate > 30:
|
||||
name += " HFR"
|
||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
||||
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
|
||||
return sanitize_filename(name)
|
||||
|
||||
|
||||
class Series(SortedKeyList, ABC):
|
||||
def __init__(self, iterable: Optional[Iterable] = None):
|
||||
super().__init__(iterable, key=lambda x: (x.season, x.number, x.year or 0))
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self:
|
||||
return super().__str__()
|
||||
return self[0].title + (f" ({self[0].year})" if self[0].year else "")
|
||||
|
||||
def tree(self, verbose: bool = False) -> Tree:
|
||||
seasons = Counter(x.season for x in self)
|
||||
num_seasons = len(seasons)
|
||||
num_episodes = sum(seasons.values())
|
||||
tree = Tree(
|
||||
f"{num_seasons} Season{['s', ''][num_seasons == 1]}, {num_episodes} Episode{['s', ''][num_episodes == 1]}",
|
||||
guide_style="bright_black",
|
||||
)
|
||||
if verbose:
|
||||
for season, episodes in seasons.items():
|
||||
season_tree = tree.add(
|
||||
f"[bold]Season {str(season).zfill(len(str(num_seasons)))}[/]: [bright_black]{episodes} episodes",
|
||||
guide_style="bright_black",
|
||||
)
|
||||
for episode in self:
|
||||
if episode.season == season:
|
||||
if episode.name:
|
||||
season_tree.add(
|
||||
f"[bold]{str(episode.number).zfill(len(str(episodes)))}.[/] "
|
||||
f"[bright_black]{episode.name}"
|
||||
)
|
||||
else:
|
||||
season_tree.add(f"[bright_black]Episode {str(episode.number).zfill(len(str(episodes)))}")
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
__all__ = ("Episode", "Series")
|
||||
144
unshackle/core/titles/movie.py
Normal file
144
unshackle/core/titles/movie.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from abc import ABC
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
from pymediainfo import MediaInfo
|
||||
from rich.tree import Tree
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
|
||||
from unshackle.core.titles.title import Title
|
||||
from unshackle.core.utilities import sanitize_filename
|
||||
|
||||
|
||||
class Movie(Title):
|
||||
def __init__(
|
||||
self,
|
||||
id_: Any,
|
||||
service: type,
|
||||
name: str,
|
||||
year: Optional[Union[int, str]] = None,
|
||||
language: Optional[Union[str, Language]] = None,
|
||||
data: Optional[Any] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(id_, service, language, data)
|
||||
|
||||
if not name:
|
||||
raise ValueError("Movie name must be provided")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError(f"Expected name to be a str, not {name!r}")
|
||||
|
||||
if year is not None:
|
||||
if isinstance(year, str) and year.isdigit():
|
||||
year = int(year)
|
||||
elif not isinstance(year, int):
|
||||
raise TypeError(f"Expected year to be an int, not {year!r}")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if year is not None and year <= 0:
|
||||
raise ValueError(f"Movie year cannot be {year}")
|
||||
|
||||
self.name = name
|
||||
self.year = year
|
||||
self.description = description
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.year:
|
||||
return f"{self.name} ({self.year})"
|
||||
return self.name
|
||||
|
||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||
primary_video_track = next(iter(media_info.video_tracks), None)
|
||||
primary_audio_track = next(iter(media_info.audio_tracks), None)
|
||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||
|
||||
# Name (Year)
|
||||
name = str(self).replace("$", "S") # e.g., Arli$$
|
||||
|
||||
# MULTi
|
||||
if unique_audio_languages > 1:
|
||||
name += " MULTi"
|
||||
|
||||
# Resolution
|
||||
if primary_video_track:
|
||||
resolution = primary_video_track.height
|
||||
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
|
||||
if len(aspect_ratio) == 1:
|
||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||
aspect_ratio.append(1)
|
||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||
# otherwise the track's height value is fine.
|
||||
# We are assuming this title is some weird aspect ratio so most
|
||||
# likely a movie or HD source, so it's most likely widescreen so
|
||||
# 16:9 canvas makes the most sense.
|
||||
resolution = int(primary_video_track.width * (9 / 16))
|
||||
name += f" {resolution}p"
|
||||
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
|
||||
# Audio Codec + Channels (+ feature)
|
||||
if primary_audio_track:
|
||||
codec = primary_audio_track.format
|
||||
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
||||
if channel_layout:
|
||||
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
|
||||
else:
|
||||
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||
channels = float(channel_count)
|
||||
|
||||
features = primary_audio_track.format_additionalfeatures or ""
|
||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||
if "JOC" in features or primary_audio_track.joc:
|
||||
name += " Atmos"
|
||||
|
||||
# Video (dynamic range + hfr +) Codec
|
||||
if primary_video_track:
|
||||
codec = primary_video_track.format
|
||||
hdr_format = primary_video_track.hdr_format_commercial
|
||||
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
|
||||
frame_rate = float(primary_video_track.frame_rate)
|
||||
if hdr_format:
|
||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||
elif trc and "HLG" in trc:
|
||||
name += " HLG"
|
||||
if frame_rate > 30:
|
||||
name += " HFR"
|
||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
||||
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
|
||||
return sanitize_filename(name)
|
||||
|
||||
|
||||
class Movies(SortedKeyList, ABC):
|
||||
def __init__(self, iterable: Optional[Iterable] = None):
|
||||
super().__init__(iterable, key=lambda x: x.year or 0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self:
|
||||
return super().__str__()
|
||||
# TODO: Assumes there's only one movie
|
||||
return self[0].name + (f" ({self[0].year})" if self[0].year else "")
|
||||
|
||||
def tree(self, verbose: bool = False) -> Tree:
|
||||
num_movies = len(self)
|
||||
tree = Tree(f"{num_movies} Movie{['s', ''][num_movies == 1]}", guide_style="bright_black")
|
||||
if verbose:
|
||||
for movie in self:
|
||||
tree.add(f"[bold]{movie.name}[/] [bright_black]({movie.year or '?'})", guide_style="bright_black")
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
__all__ = ("Movie", "Movies")
|
||||
140
unshackle/core/titles/song.py
Normal file
140
unshackle/core/titles/song.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from abc import ABC
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
from pymediainfo import MediaInfo
|
||||
from rich.tree import Tree
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import AUDIO_CODEC_MAP
|
||||
from unshackle.core.titles.title import Title
|
||||
from unshackle.core.utilities import sanitize_filename
|
||||
|
||||
|
||||
class Song(Title):
|
||||
def __init__(
|
||||
self,
|
||||
id_: Any,
|
||||
service: type,
|
||||
name: str,
|
||||
artist: str,
|
||||
album: str,
|
||||
track: int,
|
||||
disc: int,
|
||||
year: int,
|
||||
language: Optional[Union[str, Language]] = None,
|
||||
data: Optional[Any] = None,
|
||||
) -> None:
|
||||
super().__init__(id_, service, language, data)
|
||||
|
||||
if not name:
|
||||
raise ValueError("Song name must be provided")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError(f"Expected name to be a str, not {name!r}")
|
||||
|
||||
if not artist:
|
||||
raise ValueError("Song artist must be provided")
|
||||
if not isinstance(artist, str):
|
||||
raise TypeError(f"Expected artist to be a str, not {artist!r}")
|
||||
|
||||
if not album:
|
||||
raise ValueError("Song album must be provided")
|
||||
if not isinstance(album, str):
|
||||
raise TypeError(f"Expected album to be a str, not {name!r}")
|
||||
|
||||
if not track:
|
||||
raise ValueError("Song track must be provided")
|
||||
if not isinstance(track, int):
|
||||
raise TypeError(f"Expected track to be an int, not {track!r}")
|
||||
|
||||
if not disc:
|
||||
raise ValueError("Song disc must be provided")
|
||||
if not isinstance(disc, int):
|
||||
raise TypeError(f"Expected disc to be an int, not {disc!r}")
|
||||
|
||||
if not year:
|
||||
raise ValueError("Song year must be provided")
|
||||
if not isinstance(year, int):
|
||||
raise TypeError(f"Expected year to be an int, not {year!r}")
|
||||
|
||||
name = name.strip()
|
||||
artist = artist.strip()
|
||||
album = album.strip()
|
||||
|
||||
if track <= 0:
|
||||
raise ValueError(f"Song track cannot be {track}")
|
||||
if disc <= 0:
|
||||
raise ValueError(f"Song disc cannot be {disc}")
|
||||
if year <= 0:
|
||||
raise ValueError(f"Song year cannot be {year}")
|
||||
|
||||
self.name = name
|
||||
self.artist = artist
|
||||
self.album = album
|
||||
self.track = track
|
||||
self.disc = disc
|
||||
self.year = year
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{artist} - {album} ({year}) / {track:02}. {name}".format(
|
||||
artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name
|
||||
).strip()
|
||||
|
||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||
audio_track = next(iter(media_info.audio_tracks), None)
|
||||
codec = audio_track.format
|
||||
channel_layout = audio_track.channel_layout or audio_track.channellayout_original
|
||||
if channel_layout:
|
||||
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
|
||||
else:
|
||||
channel_count = audio_track.channel_s or audio_track.channels or 0
|
||||
channels = float(channel_count)
|
||||
|
||||
features = audio_track.format_additionalfeatures or ""
|
||||
|
||||
if folder:
|
||||
# Artist - Album (Year)
|
||||
name = str(self).split(" / ")[0]
|
||||
else:
|
||||
# NN. Song Name
|
||||
name = str(self).split(" / ")[1]
|
||||
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
|
||||
# Audio Codec + Channels (+ feature)
|
||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||
if "JOC" in features or audio_track.joc:
|
||||
name += " Atmos"
|
||||
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
|
||||
return sanitize_filename(name, " ")
|
||||
|
||||
|
||||
class Album(SortedKeyList, ABC):
|
||||
def __init__(self, iterable: Optional[Iterable] = None):
|
||||
super().__init__(iterable, key=lambda x: (x.album, x.disc, x.track, x.year or 0))
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self:
|
||||
return super().__str__()
|
||||
return f"{self[0].artist} - {self[0].album} ({self[0].year or '?'})"
|
||||
|
||||
def tree(self, verbose: bool = False) -> Tree:
|
||||
num_songs = len(self)
|
||||
tree = Tree(f"{num_songs} Song{['s', ''][num_songs == 1]}", guide_style="bright_black")
|
||||
if verbose:
|
||||
for song in self:
|
||||
tree.add(f"[bold]Track {song.track:02}.[/] [bright_black]({song.name})", guide_style="bright_black")
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
__all__ = ("Song", "Album")
|
||||
68
unshackle/core/titles/title.py
Normal file
68
unshackle/core/titles/title.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
from pymediainfo import MediaInfo
|
||||
|
||||
from unshackle.core.tracks import Tracks
|
||||
|
||||
|
||||
class Title:
|
||||
def __init__(
|
||||
self, id_: Any, service: type, language: Optional[Union[str, Language]] = None, data: Optional[Any] = None
|
||||
) -> None:
|
||||
"""
|
||||
Media Title from a Service.
|
||||
|
||||
Parameters:
|
||||
id_: An identifier for this specific title. It must be unique. Can be of any
|
||||
value.
|
||||
service: Service class that this title is from.
|
||||
language: The original recorded language for the title. If that information
|
||||
is not available, this should not be set to anything.
|
||||
data: Arbitrary storage for the title. Often used to store extra metadata
|
||||
information, IDs, URIs, and so on.
|
||||
"""
|
||||
if not id_: # includes 0, false, and similar values, this is intended
|
||||
raise ValueError("A unique ID must be provided")
|
||||
if hasattr(id_, "__len__") and len(id_) < 4:
|
||||
raise ValueError("The unique ID is not large enough, clash likely.")
|
||||
|
||||
if not service:
|
||||
raise ValueError("Service class must be provided")
|
||||
if not isinstance(service, type):
|
||||
raise TypeError(f"Expected service to be a Class (type), not {service!r}")
|
||||
|
||||
if language is not None:
|
||||
if isinstance(language, str):
|
||||
language = Language.get(language)
|
||||
elif not isinstance(language, Language):
|
||||
raise TypeError(f"Expected language to be a {Language} or str, not {language!r}")
|
||||
|
||||
self.id = id_
|
||||
self.service = service
|
||||
self.language = language
|
||||
self.data = data
|
||||
|
||||
self.tracks = Tracks()
|
||||
|
||||
def __eq__(self, other: Title) -> bool:
|
||||
return self.id == other.id
|
||||
|
||||
@abstractmethod
|
||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||
"""
|
||||
Get a Filename for this Title with the provided Media Info.
|
||||
All filenames should be sanitized with the sanitize_filename() utility function.
|
||||
|
||||
Parameters:
|
||||
media_info: MediaInfo object of the file this name will be used for.
|
||||
folder: This filename will be used as a folder name. Some changes may want to
|
||||
be made if this is the case.
|
||||
show_service: Show the service tag (e.g., iT, NF) in the filename.
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ("Title",)
|
||||
10
unshackle/core/tracks/__init__.py
Normal file
10
unshackle/core/tracks/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .attachment import Attachment
|
||||
from .audio import Audio
|
||||
from .chapter import Chapter
|
||||
from .chapters import Chapters
|
||||
from .subtitle import Subtitle
|
||||
from .track import Track
|
||||
from .tracks import Tracks
|
||||
from .video import Video
|
||||
|
||||
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Subtitle", "Track", "Tracks", "Video")
|
||||
146
unshackle/core/tracks/attachment.py
Normal file
146
unshackle/core/tracks/attachment.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
from zlib import crc32
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
|
||||
|
||||
class Attachment:
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[Path, str, None] = None,
|
||||
url: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
session: Optional[requests.Session] = None,
|
||||
):
|
||||
"""
|
||||
Create a new Attachment.
|
||||
|
||||
If providing a path, the file must already exist.
|
||||
If providing a URL, the file will be downloaded to the temp directory.
|
||||
Either path or url must be provided.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
path: Path to an existing file.
|
||||
url: URL to download the attachment from.
|
||||
name: Name of the attachment.
|
||||
mime_type: MIME type of the attachment.
|
||||
description: Description of the attachment.
|
||||
session: Optional requests session to use for downloading.
|
||||
"""
|
||||
if path is None and url is None:
|
||||
raise ValueError("Either path or url must be provided.")
|
||||
|
||||
if url:
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("The attachment URL must be a string.")
|
||||
|
||||
# If a URL is provided, download the file to the temp directory
|
||||
parsed_url = urlparse(url)
|
||||
file_name = os.path.basename(parsed_url.path) or "attachment"
|
||||
|
||||
# Use provided name for the file if available
|
||||
if name:
|
||||
file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}"
|
||||
|
||||
download_path = config.directories.temp / file_name
|
||||
|
||||
# Download the file
|
||||
try:
|
||||
session = session or requests.Session()
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(download_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
path = download_path
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||
|
||||
if not isinstance(path, (str, Path)):
|
||||
raise ValueError("The attachment path 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",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
}.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)
|
||||
|
||||
def delete(self) -> None:
|
||||
if self.path:
|
||||
self.path.unlink()
|
||||
self.path = None
|
||||
|
||||
@classmethod
|
||||
def from_url(
|
||||
cls,
|
||||
url: str,
|
||||
name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
session: Optional[requests.Session] = None,
|
||||
) -> "Attachment":
|
||||
"""
|
||||
Create an attachment from a URL.
|
||||
|
||||
Args:
|
||||
url: URL to download the attachment from.
|
||||
name: Name of the attachment.
|
||||
mime_type: MIME type of the attachment.
|
||||
description: Description of the attachment.
|
||||
session: Optional requests session to use for downloading.
|
||||
|
||||
Returns:
|
||||
Attachment: A new attachment instance.
|
||||
"""
|
||||
return cls(url=url, name=name, mime_type=mime_type, description=description, session=session)
|
||||
|
||||
|
||||
__all__ = ("Attachment",)
|
||||
188
unshackle/core/tracks/audio.py
Normal file
188
unshackle/core/tracks/audio.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from unshackle.core.tracks.track import Track
|
||||
|
||||
|
||||
class Audio(Track):
|
||||
class Codec(str, Enum):
|
||||
AAC = "AAC" # https://wikipedia.org/wiki/Advanced_Audio_Coding
|
||||
AC3 = "DD" # https://wikipedia.org/wiki/Dolby_Digital
|
||||
EC3 = "DD+" # https://wikipedia.org/wiki/Dolby_Digital_Plus
|
||||
OPUS = "OPUS" # https://wikipedia.org/wiki/Opus_(audio_format)
|
||||
OGG = "VORB" # https://wikipedia.org/wiki/Vorbis
|
||||
DTS = "DTS" # https://en.wikipedia.org/wiki/DTS_(company)#DTS_Digital_Surround
|
||||
ALAC = "ALAC" # https://en.wikipedia.org/wiki/Apple_Lossless_Audio_Codec
|
||||
FLAC = "FLAC" # https://en.wikipedia.org/wiki/FLAC
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Audio.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime == "mp4a":
|
||||
return Audio.Codec.AAC
|
||||
if mime == "ac-3":
|
||||
return Audio.Codec.AC3
|
||||
if mime == "ec-3":
|
||||
return Audio.Codec.EC3
|
||||
if mime == "opus":
|
||||
return Audio.Codec.OPUS
|
||||
if mime == "dtsc":
|
||||
return Audio.Codec.DTS
|
||||
if mime == "alac":
|
||||
return Audio.Codec.ALAC
|
||||
if mime == "flac":
|
||||
return Audio.Codec.FLAC
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Audio Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Audio.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
mime = codec.strip().split(".")[0]
|
||||
try:
|
||||
return Audio.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Audio Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Audio.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith("heaac"):
|
||||
return Audio.Codec.AAC
|
||||
if profile.startswith("dd-"):
|
||||
return Audio.Codec.AC3
|
||||
if profile.startswith("ddplus"):
|
||||
return Audio.Codec.EC3
|
||||
if profile.startswith("playready-oggvorbis"):
|
||||
return Audio.Codec.OGG
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Audio Codec")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: 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)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
|
||||
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.descriptive = bool(descriptive)
|
||||
|
||||
@property
|
||||
def atmos(self) -> bool:
|
||||
"""Return True if the audio track contains Dolby Atmos."""
|
||||
return bool(self.joc)
|
||||
|
||||
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,
|
||||
"Atmos" if self.atmos 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
|
||||
def parse_channels(channels: Union[str, int, float]) -> float:
|
||||
"""
|
||||
Converts a Channel string to a float representing audio channel count and layout.
|
||||
E.g. "3" -> "3.0", "2.1" -> "2.1", ".1" -> "0.1".
|
||||
|
||||
This does not validate channel strings as genuine channel counts or valid layouts.
|
||||
It does not convert the value to assume a sub speaker channel layout, e.g. 5.1->6.0.
|
||||
It also does not support expanded surround sound channel layout strings like 7.1.2.
|
||||
"""
|
||||
if isinstance(channels, str):
|
||||
# TODO: Support all possible DASH channel configurations (https://datatracker.ietf.org/doc/html/rfc8216)
|
||||
if channels.upper() == "A000":
|
||||
return 2.0
|
||||
elif channels.upper() == "F801":
|
||||
return 5.1
|
||||
elif channels.replace("ch", "").replace(".", "", 1).isdigit():
|
||||
# e.g., '2ch', '2', '2.0', '5.1ch', '5.1'
|
||||
return float(channels.replace("ch", ""))
|
||||
raise NotImplementedError(f"Unsupported Channels string value, '{channels}'")
|
||||
|
||||
return float(channels)
|
||||
|
||||
def get_track_name(self) -> Optional[str]:
|
||||
"""Return the base Track Name."""
|
||||
track_name = super().get_track_name() or ""
|
||||
flag = self.descriptive and "Descriptive"
|
||||
if flag:
|
||||
if track_name:
|
||||
flag = f" ({flag})"
|
||||
track_name += flag
|
||||
return track_name or None
|
||||
|
||||
|
||||
__all__ = ("Audio",)
|
||||
77
unshackle/core/tracks/chapter.py
Normal file
77
unshackle/core/tracks/chapter.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from zlib import crc32
|
||||
|
||||
TIMESTAMP_FORMAT = re.compile(r"^(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(?P<ms>.\d{3}|)$")
|
||||
|
||||
|
||||
class Chapter:
|
||||
def __init__(self, timestamp: Union[str, int, float], name: Optional[str] = None):
|
||||
"""
|
||||
Create a new Chapter with a Timestamp and optional name.
|
||||
|
||||
The timestamp may be in the following formats:
|
||||
- "HH:MM:SS" string, e.g., `25:05:23`.
|
||||
- "HH:MM:SS.mss" string, e.g., `25:05:23.120`.
|
||||
- a timecode integer in milliseconds, e.g., `90323120` is `25:05:23.120`.
|
||||
- a timecode float in seconds, e.g., `90323.12` is `25:05:23.120`.
|
||||
|
||||
If you have a timecode integer in seconds, just multiply it by 1000.
|
||||
If you have a timecode float in milliseconds (no decimal value), just convert
|
||||
it to an integer.
|
||||
"""
|
||||
if timestamp is None:
|
||||
raise ValueError("The timestamp must be provided.")
|
||||
|
||||
if not isinstance(timestamp, (str, int, float)):
|
||||
raise TypeError(f"Expected timestamp to be {str}, {int} or {float}, not {type(timestamp)}")
|
||||
if not isinstance(name, (str, type(None))):
|
||||
raise TypeError(f"Expected name to be {str}, not {type(name)}")
|
||||
|
||||
if not isinstance(timestamp, str):
|
||||
if isinstance(timestamp, int): # ms
|
||||
hours, remainder = divmod(timestamp, 1000 * 60 * 60)
|
||||
minutes, remainder = divmod(remainder, 1000 * 60)
|
||||
seconds, ms = divmod(remainder, 1000)
|
||||
elif isinstance(timestamp, float): # seconds.ms
|
||||
hours, remainder = divmod(timestamp, 60 * 60)
|
||||
minutes, remainder = divmod(remainder, 60)
|
||||
seconds, ms = divmod(int(remainder * 1000), 1000)
|
||||
else:
|
||||
raise TypeError
|
||||
timestamp = f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}.{str(ms).zfill(3)[:3]}"
|
||||
|
||||
timestamp_m = TIMESTAMP_FORMAT.match(timestamp)
|
||||
if not timestamp_m:
|
||||
raise ValueError(f"The timestamp format is invalid: {timestamp}")
|
||||
|
||||
hour, minute, second, ms = timestamp_m.groups()
|
||||
if not ms:
|
||||
timestamp += ".000"
|
||||
|
||||
self.timestamp = timestamp
|
||||
self.name = name
|
||||
|
||||
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, ["CHP", self.timestamp, self.name]))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the Chapter data."""
|
||||
checksum = crc32(str(self).encode("utf8"))
|
||||
return hex(checksum)
|
||||
|
||||
@property
|
||||
def named(self) -> bool:
|
||||
"""Check if Chapter is named."""
|
||||
return bool(self.name)
|
||||
|
||||
|
||||
__all__ = ("Chapter",)
|
||||
144
unshackle/core/tracks/chapters.py
Normal file
144
unshackle/core/tracks/chapters.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
from zlib import crc32
|
||||
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from unshackle.core.tracks import Chapter
|
||||
|
||||
OGM_SIMPLE_LINE_1_FORMAT = re.compile(r"^CHAPTER(?P<number>\d+)=(?P<timestamp>\d{2,}:\d{2}:\d{2}\.\d{3})$")
|
||||
OGM_SIMPLE_LINE_2_FORMAT = re.compile(r"^CHAPTER(?P<number>\d+)NAME=(?P<name>.*)$")
|
||||
|
||||
|
||||
class Chapters(SortedKeyList, ABC):
|
||||
def __init__(self, iterable: Optional[Iterable[Chapter]] = None):
|
||||
super().__init__(key=lambda x: x.timestamp or 0)
|
||||
for chapter in iterable or []:
|
||||
self.add(chapter)
|
||||
|
||||
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 "\n".join(
|
||||
[
|
||||
" | ".join(filter(bool, ["CHP", f"[{i:02}]", chapter.timestamp, chapter.name]))
|
||||
for i, chapter in enumerate(self, start=1)
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: str) -> Chapters:
|
||||
"""Load chapter data from a string."""
|
||||
lines = [line.strip() for line in data.strip().splitlines(keepends=False)]
|
||||
|
||||
if len(lines) % 2 != 0:
|
||||
raise ValueError("The number of chapter lines must be even.")
|
||||
|
||||
chapters = []
|
||||
|
||||
for line_1, line_2 in zip(lines[::2], lines[1::2]):
|
||||
line_1_match = OGM_SIMPLE_LINE_1_FORMAT.match(line_1)
|
||||
if not line_1_match:
|
||||
raise SyntaxError(f"An unexpected syntax error occurred on: {line_1}")
|
||||
line_2_match = OGM_SIMPLE_LINE_2_FORMAT.match(line_2)
|
||||
if not line_2_match:
|
||||
raise SyntaxError(f"An unexpected syntax error occurred on: {line_2}")
|
||||
|
||||
line_1_number, timestamp = line_1_match.groups()
|
||||
line_2_number, name = line_2_match.groups()
|
||||
|
||||
if line_1_number != line_2_number:
|
||||
raise SyntaxError(
|
||||
f"The chapter numbers {line_1_number} and {line_2_number} do not match on:\n{line_1}\n{line_2}"
|
||||
)
|
||||
|
||||
if not timestamp:
|
||||
raise SyntaxError(f"The timestamp is missing on: {line_1}")
|
||||
|
||||
chapters.append(Chapter(timestamp, name))
|
||||
|
||||
return cls(chapters)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> Chapters:
|
||||
"""Load chapter data from a file."""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
return cls.loads(path.read_text(encoding="utf8"))
|
||||
|
||||
def dumps(self, fallback_name: str = "") -> str:
|
||||
"""
|
||||
Return chapter data in OGM-based Simple Chapter format.
|
||||
https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
|
||||
|
||||
Parameters:
|
||||
fallback_name: Name used for Chapters without a Name set.
|
||||
|
||||
The fallback name can use the following variables in f-string style:
|
||||
|
||||
- {i}: The Chapter number starting at 1.
|
||||
E.g., `"Chapter {i}"`: "Chapter 1", "Intro", "Chapter 3".
|
||||
- {j}: A number starting at 1 that increments any time a Chapter has no name.
|
||||
E.g., `"Chapter {j}"`: "Chapter 1", "Intro", "Chapter 2".
|
||||
|
||||
These are formatted with f-strings, directives are supported.
|
||||
For example, `"Chapter {i:02}"` will result in `"Chapter 01"`.
|
||||
"""
|
||||
chapters = []
|
||||
j = 0
|
||||
|
||||
for i, chapter in enumerate(self, start=1):
|
||||
if not chapter.name:
|
||||
j += 1
|
||||
chapters.append(
|
||||
"CHAPTER{num}={time}\nCHAPTER{num}NAME={name}".format(
|
||||
num=f"{i:02}", time=chapter.timestamp, name=chapter.name or fallback_name.format(i=i, j=j)
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(chapters)
|
||||
|
||||
def dump(self, path: Union[Path, str], *args: Any, **kwargs: Any) -> int:
|
||||
"""
|
||||
Write chapter data in OGM-based Simple Chapter format to a file.
|
||||
|
||||
Parameters:
|
||||
path: The file path to write the Chapter data to, overwriting
|
||||
any existing data.
|
||||
|
||||
See `Chapters.dumps` for more parameter documentation.
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ogm_text = self.dumps(*args, **kwargs)
|
||||
return path.write_text(ogm_text, encoding="utf8")
|
||||
|
||||
def add(self, value: Chapter) -> None:
|
||||
if not isinstance(value, Chapter):
|
||||
raise TypeError(f"Can only add {Chapter} objects, not {type(value)}")
|
||||
|
||||
if any(chapter.timestamp == value.timestamp for chapter in self):
|
||||
raise ValueError(f"A Chapter with the Timestamp {value.timestamp} already exists")
|
||||
|
||||
super().add(value)
|
||||
|
||||
if not any(chapter.timestamp == "00:00:00.000" for chapter in self):
|
||||
self.add(Chapter(0))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the Chapter data."""
|
||||
checksum = crc32("\n".join([chapter.id for chapter in self]).encode("utf8"))
|
||||
return hex(checksum)
|
||||
|
||||
|
||||
__all__ = ("Chapters", "Chapter")
|
||||
726
unshackle/core/tracks/subtitle.py
Normal file
726
unshackle/core/tracks/subtitle.py
Normal file
@@ -0,0 +1,726 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional, Union
|
||||
|
||||
import pycaption
|
||||
import requests
|
||||
from construct import Container
|
||||
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
|
||||
from pycaption.geometry import Layout
|
||||
from pymp4.parser import MP4
|
||||
from subtitle_filter import Subtitles
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.utilities import try_ensure_utf8
|
||||
from unshackle.core.utils.webvtt import merge_segmented_webvtt
|
||||
|
||||
|
||||
class Subtitle(Track):
|
||||
class Codec(str, Enum):
|
||||
SubRip = "SRT" # https://wikipedia.org/wiki/SubRip
|
||||
SubStationAlpha = "SSA" # https://wikipedia.org/wiki/SubStation_Alpha
|
||||
SubStationAlphav4 = "ASS" # https://wikipedia.org/wiki/SubStation_Alpha#Advanced_SubStation_Alpha=
|
||||
TimedTextMarkupLang = "TTML" # https://wikipedia.org/wiki/Timed_Text_Markup_Language
|
||||
WebVTT = "VTT" # https://wikipedia.org/wiki/WebVTT
|
||||
# MPEG-DASH box-encapsulated subtitle formats
|
||||
fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424
|
||||
fVTT = "WVTT" # https://www.w3.org/TR/webvtt1
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.value.lower()
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Subtitle.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime == "srt":
|
||||
return Subtitle.Codec.SubRip
|
||||
elif mime == "ssa":
|
||||
return Subtitle.Codec.SubStationAlpha
|
||||
elif mime == "ass":
|
||||
return Subtitle.Codec.SubStationAlphav4
|
||||
elif mime == "ttml":
|
||||
return Subtitle.Codec.TimedTextMarkupLang
|
||||
elif mime == "vtt":
|
||||
return Subtitle.Codec.WebVTT
|
||||
elif mime == "stpp":
|
||||
return Subtitle.Codec.fTTML
|
||||
elif mime == "wvtt":
|
||||
return Subtitle.Codec.fVTT
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Subtitle Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Subtitle.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
mime = codec.strip().split(".")[0]
|
||||
try:
|
||||
return Subtitle.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Subtitle Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Subtitle.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith("webvtt"):
|
||||
return Subtitle.Codec.WebVTT
|
||||
if profile.startswith("dfxp"):
|
||||
return Subtitle.Codec.TimedTextMarkupLang
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
codec: Optional[Subtitle.Codec] = None,
|
||||
cc: bool = False,
|
||||
sdh: bool = False,
|
||||
forced: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create a new Subtitle track object.
|
||||
|
||||
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.
|
||||
- Intended as if you couldn't hear the audio at all.
|
||||
- Can have Sound as well as Dialogue, but doesn't have to.
|
||||
- Original source would be from an EIA-CC encoded stream. Typically all
|
||||
upper-case characters.
|
||||
Indicators of it being CC without knowing original source:
|
||||
- Extracted with CCExtractor, or
|
||||
- >>> (or similar) being used at the start of some or all lines, or
|
||||
- All text is uppercase or at least the majority, or
|
||||
- Subtitles are Scrolling-text style (one line appears, oldest line
|
||||
then disappears).
|
||||
Just because you downloaded it as a SRT or VTT or such, doesn't mean it
|
||||
isn't from an EIA-CC stream. And I wouldn't take the streaming services
|
||||
(CC) as gospel either as they tend to get it wrong too.
|
||||
sdh: Deaf or Hard-of-Hearing. Also known as HOH in the UK (EU?).
|
||||
- Intended as if you couldn't hear the audio at all.
|
||||
- MUST have Sound as well as Dialogue to be considered SDH.
|
||||
- It has no "syntax" or "format" but is not transmitted using archaic
|
||||
forms like EIA-CC streams, would be intended for transmission via
|
||||
SubRip (SRT), WebVTT (VTT), TTML, etc.
|
||||
If you can see important audio/sound transcriptions and not just dialogue
|
||||
and it doesn't have the indicators of CC, then it's most likely SDH.
|
||||
If it doesn't have important audio/sounds transcriptions it might just be
|
||||
regular subtitling (you wouldn't mark as CC or SDH). This would be the
|
||||
case for most translation subtitles. Like Anime for example.
|
||||
forced: Typically used if there's important information at some point in time
|
||||
like watching Dubbed content and an important Sign or Letter is shown
|
||||
or someone talking in a different language.
|
||||
Forced tracks are recommended by the Matroska Spec to be played if
|
||||
the player's current playback audio language matches a subtitle
|
||||
marked as "forced".
|
||||
However, that doesn't mean every player works like this but there is
|
||||
no other way to reliably work with Forced subtitles where multiple
|
||||
forced subtitles may be in the output file. Just know what to expect
|
||||
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)
|
||||
|
||||
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.cc = bool(cc)
|
||||
self.sdh = bool(sdh)
|
||||
self.forced = bool(forced)
|
||||
|
||||
if self.cc and self.sdh:
|
||||
raise ValueError("A text track cannot be both CC and SDH.")
|
||||
|
||||
if self.forced and (self.cc or self.sdh):
|
||||
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
|
||||
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]:
|
||||
"""Return the base Track Name."""
|
||||
track_name = super().get_track_name() or ""
|
||||
flag = self.cc and "CC" or self.sdh and "SDH" or self.forced and "Forced"
|
||||
if flag:
|
||||
if track_name:
|
||||
flag = f" ({flag})"
|
||||
track_name += flag
|
||||
return track_name or None
|
||||
|
||||
def download(
|
||||
self,
|
||||
session: requests.Session,
|
||||
prepare_drm: partial,
|
||||
max_workers: Optional[int] = None,
|
||||
progress: Optional[partial] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
):
|
||||
super().download(session, prepare_drm, max_workers, progress, cdm=cdm)
|
||||
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, # ?
|
||||
)
|
||||
|
||||
# Sanitize WebVTT timestamps before parsing
|
||||
text = Subtitle.sanitize_webvtt_timestamps(text)
|
||||
|
||||
try:
|
||||
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")
|
||||
except pycaption.exceptions.CaptionReadSyntaxError:
|
||||
# If first attempt fails, try more aggressive sanitization
|
||||
text = Subtitle.sanitize_webvtt(text)
|
||||
try:
|
||||
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")
|
||||
except Exception:
|
||||
# Keep the sanitized version even if parsing failed
|
||||
self.path.write_text(text, encoding="utf8")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_webvtt_timestamps(text: str) -> str:
|
||||
"""
|
||||
Fix invalid timestamps in WebVTT files, particularly negative timestamps.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content
|
||||
"""
|
||||
# Replace negative timestamps with 00:00:00.000
|
||||
return re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", text)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_webvtt(text: str) -> str:
|
||||
"""
|
||||
More thorough sanitization of WebVTT files to handle multiple potential issues.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content
|
||||
"""
|
||||
# Make sure we have a proper WEBVTT header
|
||||
if not text.strip().startswith("WEBVTT"):
|
||||
text = "WEBVTT\n\n" + text
|
||||
|
||||
lines = text.split("\n")
|
||||
sanitized_lines = []
|
||||
timestamp_pattern = re.compile(r"^((?:\d+:)?\d+:\d+\.\d+)\s+-->\s+((?:\d+:)?\d+:\d+\.\d+)")
|
||||
|
||||
# Skip invalid headers - keep only WEBVTT
|
||||
header_done = False
|
||||
for line in lines:
|
||||
if not header_done:
|
||||
if line.startswith("WEBVTT"):
|
||||
sanitized_lines.append("WEBVTT")
|
||||
header_done = True
|
||||
continue
|
||||
|
||||
# Replace negative timestamps
|
||||
if "-" in line and "-->" in line:
|
||||
line = re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", line)
|
||||
|
||||
# Validate timestamp format
|
||||
match = timestamp_pattern.match(line)
|
||||
if match:
|
||||
start_time = match.group(1)
|
||||
end_time = match.group(2)
|
||||
|
||||
# Ensure proper format with hours if missing
|
||||
if start_time.count(":") == 1:
|
||||
start_time = f"00:{start_time}"
|
||||
if end_time.count(":") == 1:
|
||||
end_time = f"00:{end_time}"
|
||||
|
||||
line = f"{start_time} --> {end_time}"
|
||||
|
||||
sanitized_lines.append(line)
|
||||
|
||||
return "\n".join(sanitized_lines)
|
||||
|
||||
def convert(self, codec: Subtitle.Codec) -> Path:
|
||||
"""
|
||||
Convert this Subtitle to another Format.
|
||||
|
||||
The file path location of the Subtitle data will be kept at the same
|
||||
location but the file extension will be changed appropriately.
|
||||
|
||||
Supported formats:
|
||||
- SubRip - SubtitleEdit or pycaption.SRTWriter
|
||||
- TimedTextMarkupLang - SubtitleEdit or pycaption.DFXPWriter
|
||||
- WebVTT - SubtitleEdit or pycaption.WebVTTWriter
|
||||
- SubStationAlphav4 - SubtitleEdit
|
||||
- fTTML* - custom code using some pycaption functions
|
||||
- fVTT* - custom code using some pycaption functions
|
||||
*: Can read from format, but cannot convert to format
|
||||
|
||||
Note: It currently prioritizes using SubtitleEdit over PyCaption as
|
||||
I have personally noticed more oddities with PyCaption parsing over
|
||||
SubtitleEdit. Especially when working with TTML/DFXP where it would
|
||||
often have timecodes and stuff mixed in/duplicated.
|
||||
|
||||
Returns the new file path of the Subtitle.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if self.codec == codec:
|
||||
return self.path
|
||||
|
||||
output_path = self.path.with_suffix(f".{codec.value.lower()}")
|
||||
original_path = self.path
|
||||
|
||||
if binaries.SubtitleEdit and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT):
|
||||
sub_edit_format = {
|
||||
Subtitle.Codec.SubStationAlphav4: "AdvancedSubStationAlpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "TimedText1.0",
|
||||
}.get(codec, codec.name)
|
||||
sub_edit_args = [
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
sub_edit_format,
|
||||
f"/outputfilename:{output_path.name}",
|
||||
"/encoding:utf8",
|
||||
]
|
||||
if codec == Subtitle.Codec.SubRip:
|
||||
sub_edit_args.append("/ConvertColorsToDialog")
|
||||
subprocess.run(sub_edit_args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
writer = {
|
||||
# pycaption generally only supports these subtitle formats
|
||||
Subtitle.Codec.SubRip: pycaption.SRTWriter,
|
||||
Subtitle.Codec.TimedTextMarkupLang: pycaption.DFXPWriter,
|
||||
Subtitle.Codec.WebVTT: pycaption.WebVTTWriter,
|
||||
}.get(codec)
|
||||
if writer is None:
|
||||
raise NotImplementedError(f"Cannot yet convert {self.codec.name} to {codec.name}.")
|
||||
|
||||
caption_set = self.parse(self.path.read_bytes(), self.codec)
|
||||
Subtitle.merge_same_cues(caption_set)
|
||||
subtitle_text = writer().write(caption_set)
|
||||
|
||||
output_path.write_text(subtitle_text, encoding="utf8")
|
||||
|
||||
if original_path.exists() and original_path != output_path:
|
||||
original_path.unlink()
|
||||
|
||||
self.path = output_path
|
||||
self.codec = codec
|
||||
|
||||
if callable(self.OnConverted):
|
||||
self.OnConverted(codec)
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def parse(data: bytes, codec: Subtitle.Codec) -> pycaption.CaptionSet:
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Subtitle data must be parsed as bytes data, not {type(data).__name__}")
|
||||
|
||||
try:
|
||||
if codec == Subtitle.Codec.SubRip:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
caption_set = pycaption.SRTReader().read(text)
|
||||
elif codec == Subtitle.Codec.fTTML:
|
||||
caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList)
|
||||
for segment in (
|
||||
Subtitle.parse(box.data, Subtitle.Codec.TimedTextMarkupLang)
|
||||
for box in MP4.parse_stream(BytesIO(data))
|
||||
if box.type == b"mdat"
|
||||
):
|
||||
for lang in segment.get_languages():
|
||||
caption_lists[lang].extend(segment.get_captions(lang))
|
||||
caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists)
|
||||
elif codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
text = text.replace("tt:", "")
|
||||
# negative size values aren't allowed in TTML/DFXP spec, replace with 0
|
||||
text = re.sub(r'"(-\d+(\.\d+)?(px|em|%|c|pt))"', '"0"', text)
|
||||
caption_set = pycaption.DFXPReader().read(text)
|
||||
elif codec == Subtitle.Codec.fVTT:
|
||||
caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList)
|
||||
caption_list, language = Subtitle.merge_segmented_wvtt(data)
|
||||
caption_lists[language] = caption_list
|
||||
caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists)
|
||||
elif codec == Subtitle.Codec.WebVTT:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
text = Subtitle.sanitize_broken_webvtt(text)
|
||||
text = Subtitle.space_webvtt_headers(text)
|
||||
caption_set = pycaption.WebVTTReader().read(text)
|
||||
else:
|
||||
raise ValueError(f'Unknown Subtitle format "{codec}"...')
|
||||
except pycaption.exceptions.CaptionReadSyntaxError as e:
|
||||
raise SyntaxError(f'A syntax error has occurred when reading the "{codec}" subtitle: {e}')
|
||||
except pycaption.exceptions.CaptionReadNoCaptions:
|
||||
return pycaption.CaptionSet({"en": []})
|
||||
|
||||
# remove empty caption lists or some code breaks, especially if it's the first list
|
||||
for language in caption_set.get_languages():
|
||||
if not caption_set.get_captions(language):
|
||||
# noinspection PyProtectedMember
|
||||
del caption_set._captions[language]
|
||||
|
||||
return caption_set
|
||||
|
||||
@staticmethod
|
||||
def sanitize_broken_webvtt(text: str) -> str:
|
||||
"""
|
||||
Remove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content with corrupted lines removed
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
sanitized_lines = []
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
# Skip empty lines
|
||||
if not lines[i].strip():
|
||||
sanitized_lines.append(lines[i])
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for timestamp lines
|
||||
if "-->" in lines[i]:
|
||||
# Validate timestamp format
|
||||
timestamp_parts = lines[i].split("-->")
|
||||
if len(timestamp_parts) != 2 or not timestamp_parts[1].strip() or timestamp_parts[1].strip() == "0":
|
||||
# Skip this timestamp and its content until next timestamp or end
|
||||
j = i + 1
|
||||
while j < len(lines) and "-->" not in lines[j] and lines[j].strip():
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Add valid timestamp line
|
||||
sanitized_lines.append(lines[i])
|
||||
else:
|
||||
# Add non-timestamp line
|
||||
sanitized_lines.append(lines[i])
|
||||
|
||||
i += 1
|
||||
|
||||
return "\n".join(sanitized_lines)
|
||||
|
||||
@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
|
||||
def merge_same_cues(caption_set: pycaption.CaptionSet):
|
||||
"""Merge captions with the same timecodes and text as one in-place."""
|
||||
for lang in caption_set.get_languages():
|
||||
captions = caption_set.get_captions(lang)
|
||||
last_caption = None
|
||||
concurrent_captions = pycaption.CaptionList()
|
||||
merged_captions = pycaption.CaptionList()
|
||||
for caption in captions:
|
||||
if last_caption:
|
||||
if (caption.start, caption.end) == (last_caption.start, last_caption.end):
|
||||
if caption.get_text() != last_caption.get_text():
|
||||
concurrent_captions.append(caption)
|
||||
last_caption = caption
|
||||
continue
|
||||
else:
|
||||
merged_captions.append(pycaption.base.merge(concurrent_captions))
|
||||
concurrent_captions = [caption]
|
||||
last_caption = caption
|
||||
|
||||
if concurrent_captions:
|
||||
merged_captions.append(pycaption.base.merge(concurrent_captions))
|
||||
if merged_captions:
|
||||
caption_set.set_captions(lang, merged_captions)
|
||||
|
||||
@staticmethod
|
||||
def merge_segmented_wvtt(data: bytes, period_start: float = 0.0) -> tuple[CaptionList, Optional[str]]:
|
||||
"""
|
||||
Convert Segmented DASH WebVTT cues into a pycaption Caption List.
|
||||
Also returns an ISO 639-2 alpha-3 language code if available.
|
||||
|
||||
Code ported originally by xhlove to Python from shaka-player.
|
||||
Has since been improved upon by rlaphoenix using pymp4 and
|
||||
pycaption functions.
|
||||
"""
|
||||
captions = CaptionList()
|
||||
|
||||
# init:
|
||||
saw_wvtt_box = False
|
||||
timescale = None
|
||||
language = None
|
||||
|
||||
# media:
|
||||
# > tfhd
|
||||
default_duration = None
|
||||
# > tfdt
|
||||
saw_tfdt_box = False
|
||||
base_time = 0
|
||||
# > trun
|
||||
saw_trun_box = False
|
||||
samples = []
|
||||
|
||||
def flatten_boxes(box: Container) -> Iterable[Container]:
|
||||
for child in box:
|
||||
if hasattr(child, "children"):
|
||||
yield from flatten_boxes(child.children)
|
||||
del child["children"]
|
||||
if hasattr(child, "entries"):
|
||||
yield from flatten_boxes(child.entries)
|
||||
del child["entries"]
|
||||
# some boxes (mainly within 'entries') uses format not type
|
||||
child["type"] = child.get("type") or child.get("format")
|
||||
yield child
|
||||
|
||||
for box in flatten_boxes(MP4.parse_stream(BytesIO(data))):
|
||||
# init
|
||||
if box.type == b"mdhd":
|
||||
timescale = box.timescale
|
||||
language = box.language
|
||||
|
||||
if box.type == b"wvtt":
|
||||
saw_wvtt_box = True
|
||||
|
||||
# media
|
||||
if box.type == b"styp":
|
||||
# essentially the start of each segment
|
||||
# media var resets
|
||||
# > tfhd
|
||||
default_duration = None
|
||||
# > tfdt
|
||||
saw_tfdt_box = False
|
||||
base_time = 0
|
||||
# > trun
|
||||
saw_trun_box = False
|
||||
samples = []
|
||||
|
||||
if box.type == b"tfhd":
|
||||
if box.flags.default_sample_duration_present:
|
||||
default_duration = box.default_sample_duration
|
||||
|
||||
if box.type == b"tfdt":
|
||||
saw_tfdt_box = True
|
||||
base_time = box.baseMediaDecodeTime
|
||||
|
||||
if box.type == b"trun":
|
||||
saw_trun_box = True
|
||||
samples = box.sample_info
|
||||
|
||||
if box.type == b"mdat":
|
||||
if not timescale:
|
||||
raise ValueError("Timescale was not found in the Segmented WebVTT.")
|
||||
if not saw_wvtt_box:
|
||||
raise ValueError("The WVTT box was not found in the Segmented WebVTT.")
|
||||
if not saw_tfdt_box:
|
||||
raise ValueError("The TFDT box was not found in the Segmented WebVTT.")
|
||||
if not saw_trun_box:
|
||||
raise ValueError("The TRUN box was not found in the Segmented WebVTT.")
|
||||
|
||||
vttc_boxes = MP4.parse_stream(BytesIO(box.data))
|
||||
current_time = base_time + period_start
|
||||
|
||||
for sample, vttc_box in zip(samples, vttc_boxes):
|
||||
duration = sample.sample_duration or default_duration
|
||||
if sample.sample_composition_time_offsets:
|
||||
current_time += sample.sample_composition_time_offsets
|
||||
|
||||
start_time = current_time
|
||||
end_time = current_time + (duration or 0)
|
||||
current_time = end_time
|
||||
|
||||
if vttc_box.type == b"vtte":
|
||||
# vtte is a vttc that's empty, skip
|
||||
continue
|
||||
|
||||
layout: Optional[Layout] = None
|
||||
nodes: list[CaptionNode] = []
|
||||
|
||||
for cue_box in vttc_box.children:
|
||||
if cue_box.type == b"vsid":
|
||||
# this is a V(?) Source ID box, we don't care
|
||||
continue
|
||||
if cue_box.type == b"sttg":
|
||||
layout = Layout(webvtt_positioning=cue_box.settings)
|
||||
elif cue_box.type == b"payl":
|
||||
nodes.extend(
|
||||
[
|
||||
node
|
||||
for line in cue_box.cue_text.split("\n")
|
||||
for node in [
|
||||
CaptionNode.create_text(WebVTTReader()._decode(line)),
|
||||
CaptionNode.create_break(),
|
||||
]
|
||||
]
|
||||
)
|
||||
nodes.pop()
|
||||
|
||||
if nodes:
|
||||
caption = Caption(
|
||||
start=start_time * timescale, # as microseconds
|
||||
end=end_time * timescale,
|
||||
nodes=nodes,
|
||||
layout_info=layout,
|
||||
)
|
||||
p_caption = captions[-1] if captions else None
|
||||
if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes):
|
||||
# it's a duplicate, but lets take its end time
|
||||
p_caption.end = caption.end
|
||||
continue
|
||||
captions.append(caption)
|
||||
|
||||
return captions, language
|
||||
|
||||
def strip_hearing_impaired(self) -> None:
|
||||
"""
|
||||
Strip captions for hearing impaired (SDH).
|
||||
It uses SubtitleEdit if available, otherwise filter-subs.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if binaries.SubtitleEdit:
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
output_format,
|
||||
"/encoding:utf8",
|
||||
"/overwrite",
|
||||
"/RemoveTextForHI",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
sub = Subtitles(self.path)
|
||||
sub.filter(rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True)
|
||||
sub.save()
|
||||
|
||||
def reverse_rtl(self) -> None:
|
||||
"""
|
||||
Reverse RTL (Right to Left) Start/End on Captions.
|
||||
This can be used to fix the positioning of sentence-ending characters.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if not binaries.SubtitleEdit:
|
||||
raise EnvironmentError("SubtitleEdit executable not found...")
|
||||
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
output_format,
|
||||
"/ReverseRtlStartEnd",
|
||||
"/encoding:utf8",
|
||||
"/overwrite",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ("Subtitle",)
|
||||
597
unshackle/core/tracks/track.py
Normal file
597
unshackle/core/tracks/track.py
Normal file
@@ -0,0 +1,597 @@
|
||||
import base64
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional, Union
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
||||
from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
|
||||
|
||||
class Track:
|
||||
class Descriptor(Enum):
|
||||
URL = 1 # Direct URL, nothing fancy
|
||||
HLS = 2 # https://en.wikipedia.org/wiki/HTTP_Live_Streaming
|
||||
DASH = 3 # https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP
|
||||
ISM = 4 # https://learn.microsoft.com/en-us/silverlight/smooth-streaming
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Union[str, list[str]],
|
||||
language: Union[Language, str],
|
||||
is_original_lang: bool = False,
|
||||
descriptor: Descriptor = Descriptor.URL,
|
||||
needs_repack: bool = False,
|
||||
name: Optional[str] = None,
|
||||
drm: Optional[Iterable[DRM_T]] = None,
|
||||
edition: Optional[str] = None,
|
||||
downloader: Optional[Callable] = None,
|
||||
data: Optional[Union[dict, defaultdict]] = None,
|
||||
id_: Optional[str] = None,
|
||||
extra: Optional[Any] = 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,
|
||||
"n_m3u8dl_re": n_m3u8dl_re,
|
||||
}[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 {}
|
||||
self.extra: Any = extra or {} # allow anything for extra, but default to a dict
|
||||
|
||||
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_
|
||||
|
||||
# TODO: Currently using OnFoo event naming, change to just segment_filter
|
||||
self.OnSegmentFilter: Optional[Callable] = None
|
||||
|
||||
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 __eq__(self, other: Any) -> bool:
|
||||
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,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
):
|
||||
"""Download and optionally Decrypt this Track."""
|
||||
from unshackle.core.manifests import DASH, HLS, ISM
|
||||
|
||||
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.downloader.__name__ == "n_m3u8dl_re":
|
||||
self.downloader = requests
|
||||
|
||||
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,
|
||||
cdm=cdm,
|
||||
)
|
||||
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,
|
||||
cdm=cdm,
|
||||
)
|
||||
elif self.descriptor == self.Descriptor.ISM:
|
||||
ISM.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,
|
||||
cdm=cdm,
|
||||
)
|
||||
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.get_drm_for_cdm(cdm)
|
||||
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")
|
||||
elif isinstance(drm, PlayReady):
|
||||
# license and grab content keys
|
||||
if not prepare_drm:
|
||||
raise ValueError("prepare_drm func must be supplied to use PlayReady 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")
|
||||
elif track_type != "Subtitle" and self.downloader.__name__ == "n_m3u8dl_re":
|
||||
progress(downloaded="[red]FAILED")
|
||||
error = f"[N_m3u8DL-RE]: {self.descriptor} is currently not supported"
|
||||
raise ValueError(error)
|
||||
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("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.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]:
|
||||
"""Get the Track Name."""
|
||||
return self.name
|
||||
|
||||
def get_drm_for_cdm(self, cdm: Optional[object]) -> Optional[DRM_T]:
|
||||
"""Return the DRM matching the provided CDM, if available."""
|
||||
if not self.drm:
|
||||
return None
|
||||
|
||||
if isinstance(cdm, WidevineCdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, Widevine):
|
||||
return drm
|
||||
elif isinstance(cdm, PlayReadyCdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, PlayReady):
|
||||
return drm
|
||||
|
||||
return self.drm[0]
|
||||
|
||||
def get_key_id(self, init_data: Optional[bytes] = None, *args, **kwargs) -> Optional[UUID]:
|
||||
"""
|
||||
Probe the DRM encryption Key ID (KID) for this specific track.
|
||||
|
||||
It currently supports finding the Key ID by probing the track's stream
|
||||
with ffprobe for `enc_key_id` data, as well as for mp4 `tenc` (Track
|
||||
Encryption) boxes.
|
||||
|
||||
It explicitly ignores PSSH information like the `PSSH` box, as the box
|
||||
is likely to contain multiple Key IDs that may or may not be for this
|
||||
specific track.
|
||||
|
||||
To retrieve the initialization segment, this method calls :meth:`get_init_segment`
|
||||
with the positional and keyword arguments. The return value of `get_init_segment`
|
||||
is then used to determine the Key ID.
|
||||
|
||||
Returns:
|
||||
The Key ID as a UUID object, or None if the Key ID could not be determined.
|
||||
"""
|
||||
if not init_data:
|
||||
init_data = self.get_init_segment(*args, **kwargs)
|
||||
if not isinstance(init_data, bytes):
|
||||
raise TypeError(f"Expected init_data to be bytes, not {init_data!r}")
|
||||
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
return UUID(bytes=base64.b64decode(enc_key_id))
|
||||
|
||||
for tenc in get_boxes(init_data, b"tenc"):
|
||||
if tenc.key_ID.int != 0:
|
||||
return tenc.key_ID
|
||||
|
||||
for uuid_box in get_boxes(init_data, b"uuid"):
|
||||
if uuid_box.extended_type == UUID("8974dbce-7be7-4c51-84f9-7148f9882554"): # tenc
|
||||
tenc = uuid_box.data
|
||||
if tenc.key_ID.int != 0:
|
||||
return tenc.key_ID
|
||||
|
||||
def get_init_segment(
|
||||
self,
|
||||
maximum_size: int = 20000,
|
||||
url: Optional[str] = None,
|
||||
byte_range: Optional[str] = None,
|
||||
session: Optional[Session] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Get the Track's Initial Segment Data Stream.
|
||||
|
||||
HLS and DASH tracks must explicitly provide a URL to the init segment or file.
|
||||
Providing the byte-range for the init segment is recommended where possible.
|
||||
|
||||
If `byte_range` is not set, it will make a HEAD request and check the size of
|
||||
the file. If the size could not be determined, it will download up to the first
|
||||
20KB only, which should contain the entirety of the init segment. You may
|
||||
override this by changing the `maximum_size`.
|
||||
|
||||
The default maximum_size of 20000 (20KB) is a tried-and-tested value that
|
||||
seems to work well across the board.
|
||||
|
||||
Parameters:
|
||||
maximum_size: Size to assume as the content length if byte-range is not
|
||||
used, the content size could not be determined, or the content size
|
||||
is larger than it. A value of 20000 (20KB) or higher is recommended.
|
||||
url: Explicit init map or file URL to probe from.
|
||||
byte_range: Range of bytes to download from the explicit or implicit URL.
|
||||
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:
|
||||
session = Session()
|
||||
|
||||
content_length = maximum_size
|
||||
|
||||
if byte_range:
|
||||
if not isinstance(byte_range, str):
|
||||
raise TypeError(f"Expected byte_range to be a str, not {byte_range!r}")
|
||||
if not re.match(r"^\d+-\d+$", byte_range):
|
||||
raise ValueError(f"The value of byte_range is unrecognized: '{byte_range}'")
|
||||
start, end = byte_range.split("-")
|
||||
if start > end:
|
||||
raise ValueError(f"The start range cannot be greater than the end range: {start}>{end}")
|
||||
else:
|
||||
size_test = session.head(url)
|
||||
if "Content-Length" in size_test.headers:
|
||||
content_length_header = int(size_test.headers["Content-Length"])
|
||||
if content_length_header > 0:
|
||||
content_length = min(content_length_header, maximum_size)
|
||||
range_test = session.head(url, headers={"Range": "bytes=0-1"})
|
||||
if range_test.status_code == 206:
|
||||
byte_range = f"0-{content_length - 1}"
|
||||
|
||||
if byte_range:
|
||||
res = session.get(url=url, headers={"Range": f"bytes={byte_range}"})
|
||||
res.raise_for_status()
|
||||
init_data = res.content
|
||||
else:
|
||||
init_data = None
|
||||
with session.get(url, stream=True) as s:
|
||||
for chunk in s.iter_content(content_length):
|
||||
init_data = chunk
|
||||
break
|
||||
if not init_data:
|
||||
raise ValueError(f"Failed to read {content_length} bytes from the track URI.")
|
||||
|
||||
return init_data
|
||||
|
||||
def repackage(self) -> None:
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("Cannot repackage a Track that has not been downloaded.")
|
||||
|
||||
if not binaries.FFMPEG:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
original_path = self.path
|
||||
output_path = original_path.with_stem(f"{original_path.stem}_repack")
|
||||
|
||||
def _ffmpeg(extra_args: list[str] = None):
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
original_path,
|
||||
*(extra_args or []),
|
||||
# Following are very important!
|
||||
"-map_metadata",
|
||||
"-1", # don't transfer metadata to output file
|
||||
"-fflags",
|
||||
"bitexact", # only have minimal tag data, reproducible mux
|
||||
"-codec",
|
||||
"copy",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
_ffmpeg()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if b"Malformed AAC bitstream detected" in e.stderr:
|
||||
# e.g., TruTV's dodgy encodes
|
||||
_ffmpeg(["-y", "-bsf:a", "aac_adtstoasc"])
|
||||
else:
|
||||
raise
|
||||
|
||||
original_path.unlink()
|
||||
self.path = output_path
|
||||
|
||||
|
||||
__all__ = ("Track",)
|
||||
434
unshackle/core/tracks/tracks.py
Normal file
434
unshackle/core/tracks/tracks.py
Normal file
@@ -0,0 +1,434 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, Optional, Sequence, Union
|
||||
|
||||
from langcodes import Language, closest_supported_match
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.tracks.attachment import Attachment
|
||||
from unshackle.core.tracks.audio import Audio
|
||||
from unshackle.core.tracks.chapters import Chapter, Chapters
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.tracks.video import Video
|
||||
from unshackle.core.utilities import is_close_match, sanitize_filename
|
||||
from unshackle.core.utils.collections import as_list, flatten
|
||||
|
||||
|
||||
class Tracks:
|
||||
"""
|
||||
Video, Audio, Subtitle, Chapter, and Attachment Track Store.
|
||||
It provides convenience functions for listing, sorting, and selecting tracks.
|
||||
"""
|
||||
|
||||
TRACK_ORDER_MAP = {Video: 0, Audio: 1, Subtitle: 2, Chapter: 3, Attachment: 4}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
):
|
||||
self.videos: list[Video] = []
|
||||
self.audio: list[Audio] = []
|
||||
self.subtitles: list[Subtitle] = []
|
||||
self.chapters = Chapters()
|
||||
self.attachments: list[Attachment] = []
|
||||
|
||||
if args:
|
||||
self.add(args)
|
||||
|
||||
def __iter__(self) -> Iterator[AnyTrack]:
|
||||
return iter(as_list(self.videos, self.audio, self.subtitles))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.videos) + len(self.audio) + len(self.subtitles)
|
||||
|
||||
def __add__(
|
||||
self,
|
||||
other: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
) -> Tracks:
|
||||
self.add(other)
|
||||
return self
|
||||
|
||||
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:
|
||||
rep = {Video: [], Audio: [], Subtitle: [], Chapter: [], Attachment: []}
|
||||
tracks = [*list(self), *self.chapters]
|
||||
|
||||
for track in sorted(tracks, key=lambda t: self.TRACK_ORDER_MAP[type(t)]):
|
||||
if not rep[type(track)]:
|
||||
count = sum(type(x) is type(track) for x in tracks)
|
||||
rep[type(track)].append(
|
||||
"{count} {type} Track{plural}{colon}".format(
|
||||
count=count,
|
||||
type=track.__class__.__name__,
|
||||
plural="s" if count != 1 else "",
|
||||
colon=":" if count > 0 else "",
|
||||
)
|
||||
)
|
||||
rep[type(track)].append(str(track))
|
||||
|
||||
for type_ in list(rep):
|
||||
if not rep[type_]:
|
||||
del rep[type_]
|
||||
continue
|
||||
rep[type_] = "\n".join([rep[type_][0]] + [f"├─ {x}" for x in rep[type_][1:-1]] + [f"└─ {rep[type_][-1]}"])
|
||||
rep = "\n".join(list(rep.values()))
|
||||
|
||||
return rep
|
||||
|
||||
def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]:
|
||||
all_tracks = [*list(self), *self.chapters, *self.attachments]
|
||||
|
||||
progress_callables = []
|
||||
|
||||
tree = Tree("", hide_root=True)
|
||||
for track_type in self.TRACK_ORDER_MAP:
|
||||
tracks = list(x for x in all_tracks if isinstance(x, track_type))
|
||||
if not tracks:
|
||||
continue
|
||||
num_tracks = len(tracks)
|
||||
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}")
|
||||
for track in tracks:
|
||||
if add_progress and track_type not in (Chapter, Attachment):
|
||||
progress = Progress(
|
||||
SpinnerColumn(finished_text=""),
|
||||
BarColumn(),
|
||||
"•",
|
||||
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
||||
"•",
|
||||
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
|
||||
console=console,
|
||||
speed_estimate_period=10,
|
||||
)
|
||||
task = progress.add_task("", downloaded="-")
|
||||
progress_callables.append(partial(progress.update, task_id=task))
|
||||
track_table = Table.grid()
|
||||
track_table.add_row(str(track)[6:], style="text2")
|
||||
track_table.add_row(progress)
|
||||
tracks_tree.add(track_table)
|
||||
else:
|
||||
tracks_tree.add(str(track)[6:], style="text2")
|
||||
|
||||
return tree, progress_callables
|
||||
|
||||
def exists(self, by_id: Optional[str] = None, by_url: Optional[Union[str, list[str]]] = None) -> bool:
|
||||
"""Check if a track already exists by various methods."""
|
||||
if by_id: # recommended
|
||||
return any(x.id == by_id for x in self)
|
||||
if by_url:
|
||||
return any(x.url == by_url for x in self)
|
||||
return False
|
||||
|
||||
def add(
|
||||
self,
|
||||
tracks: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
warn_only: bool = False,
|
||||
) -> None:
|
||||
"""Add a provided track to its appropriate array and ensuring it's not a duplicate."""
|
||||
if isinstance(tracks, Tracks):
|
||||
tracks = [*list(tracks), *tracks.chapters, *tracks.attachments]
|
||||
|
||||
duplicates = 0
|
||||
for track in flatten(tracks):
|
||||
if self.exists(by_id=track.id):
|
||||
if not warn_only:
|
||||
raise ValueError(
|
||||
"One or more of the provided Tracks is a duplicate. "
|
||||
"Track IDs must be unique but accurate using static values. The "
|
||||
"value should stay the same no matter when you request the same "
|
||||
"content. Use a value that has relation to the track content "
|
||||
"itself and is static or permanent and not random/RNG data that "
|
||||
"wont change each refresh or conflict in edge cases."
|
||||
)
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
if isinstance(track, Video):
|
||||
self.videos.append(track)
|
||||
elif isinstance(track, Audio):
|
||||
self.audio.append(track)
|
||||
elif isinstance(track, Subtitle):
|
||||
self.subtitles.append(track)
|
||||
elif isinstance(track, Chapter):
|
||||
self.chapters.add(track)
|
||||
elif isinstance(track, Attachment):
|
||||
self.attachments.append(track)
|
||||
else:
|
||||
raise ValueError("Track type was not set or is invalid.")
|
||||
|
||||
log = logging.getLogger("Tracks")
|
||||
|
||||
if duplicates:
|
||||
log.warning(f" - Found and skipped {duplicates} duplicate tracks...")
|
||||
|
||||
def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort video tracks by bitrate, and optionally language."""
|
||||
if not self.videos:
|
||||
return
|
||||
# bitrate
|
||||
self.videos.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
language = next((x.language for x in self.videos if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.videos.sort(key=lambda x: str(x.language))
|
||||
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
||||
if not self.audio:
|
||||
return
|
||||
# bitrate
|
||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# descriptive
|
||||
self.audio.sort(key=lambda x: str(x.language) if x.descriptive else "")
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
language = next((x.language for x in self.audio if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.audio.sort(key=lambda x: str(x.language))
|
||||
self.audio.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_subtitles(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""
|
||||
Sort subtitle tracks by various track attributes to a common P2P standard.
|
||||
You may optionally provide a sequence of languages to prioritize to the top.
|
||||
|
||||
Section Order:
|
||||
- by_language groups prioritized to top, and ascending alphabetically
|
||||
- then rest ascending alphabetically after the prioritized groups
|
||||
(Each section ascending alphabetically, but separated)
|
||||
|
||||
Language Group Order:
|
||||
- Forced
|
||||
- Normal
|
||||
- Hard of Hearing (SDH/CC)
|
||||
(Least to most captions expected in the subtitle)
|
||||
"""
|
||||
if not self.subtitles:
|
||||
return
|
||||
# language groups
|
||||
self.subtitles.sort(key=lambda x: str(x.language))
|
||||
self.subtitles.sort(key=lambda x: x.sdh or x.cc)
|
||||
self.subtitles.sort(key=lambda x: x.forced, reverse=True)
|
||||
# sections
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) == "all":
|
||||
language = next((x.language for x in self.subtitles if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.subtitles.sort(key=lambda x: is_close_match(language, [x.language]), reverse=True)
|
||||
|
||||
def select_video(self, x: Callable[[Video], bool]) -> None:
|
||||
self.videos = list(filter(x, self.videos))
|
||||
|
||||
def select_audio(self, x: Callable[[Audio], bool]) -> None:
|
||||
self.audio = list(filter(x, self.audio))
|
||||
|
||||
def select_subtitles(self, x: Callable[[Subtitle], bool]) -> None:
|
||||
self.subtitles = list(filter(x, self.subtitles))
|
||||
|
||||
def by_resolutions(self, resolutions: list[int], per_resolution: int = 0) -> None:
|
||||
# Note: Do not merge these list comprehensions. They must be done separately so the results
|
||||
# from the 16:9 canvas check is only used if there's no exact height resolution match.
|
||||
selected = []
|
||||
for resolution in resolutions:
|
||||
matches = [ # exact matches
|
||||
x for x in self.videos if x.height == resolution
|
||||
]
|
||||
if not matches:
|
||||
matches = [ # 16:9 canvas matches
|
||||
x for x in self.videos if int(x.width * (9 / 16)) == resolution
|
||||
]
|
||||
selected.extend(matches[: per_resolution or None])
|
||||
self.videos = selected
|
||||
|
||||
@staticmethod
|
||||
def by_language(tracks: list[TrackT], languages: list[str], per_language: int = 0) -> list[TrackT]:
|
||||
selected = []
|
||||
for language in languages:
|
||||
selected.extend(
|
||||
[x for x in tracks if closest_supported_match(x.language, [language], LANGUAGE_MAX_DISTANCE)][
|
||||
: per_language or None
|
||||
]
|
||||
)
|
||||
return selected
|
||||
|
||||
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]:
|
||||
"""
|
||||
Multiplex all the Tracks into a Matroska Container file.
|
||||
|
||||
Parameters:
|
||||
title: Set the Matroska Container file title. Usually displayed in players
|
||||
instead of the filename if set.
|
||||
delete: Delete all track files after multiplexing.
|
||||
progress: Update a rich progress bar via `completed=...`. This must be the
|
||||
progress object's update() func, pre-set with task id via functools.partial.
|
||||
"""
|
||||
cl = [
|
||||
"mkvmerge",
|
||||
"--no-date", # remove dates from the output for security
|
||||
]
|
||||
|
||||
if config.muxing.get("set_title", True):
|
||||
cl.extend(["--title", title])
|
||||
|
||||
for i, vt in enumerate(self.videos):
|
||||
if not vt.path or not vt.path.exists():
|
||||
raise ValueError("Video Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
|
||||
cl.extend(
|
||||
[
|
||||
"--language",
|
||||
f"0:{vt.language}",
|
||||
"--default-track",
|
||||
f"0:{i == 0}",
|
||||
"--original-flag",
|
||||
f"0:{vt.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
"(",
|
||||
str(vt.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
for i, at in enumerate(self.audio):
|
||||
if not at.path or not at.path.exists():
|
||||
raise ValueError("Audio Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=at)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{at.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{at.language}",
|
||||
"--default-track",
|
||||
f"0:{at.is_original_lang}",
|
||||
"--visual-impaired-flag",
|
||||
f"0:{at.descriptive}",
|
||||
"--original-flag",
|
||||
f"0:{at.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
"(",
|
||||
str(at.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
if self.chapters:
|
||||
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
||||
title=sanitize_filename(title), random=self.chapters.id
|
||||
)
|
||||
self.chapters.dump(chapters_path, fallback_name=config.chapter_fallback_name)
|
||||
cl.extend(["--chapter-charset", "UTF-8", "--chapters", str(chapters_path)])
|
||||
else:
|
||||
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 = (
|
||||
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.subtitles[0].path.with_suffix(".muxed.mks")
|
||||
if self.subtitles
|
||||
else chapters_path.with_suffix(".muxed.mkv")
|
||||
if self.chapters
|
||||
else None
|
||||
)
|
||||
if not output_path:
|
||||
raise ValueError("No tracks provided, at least one track must be provided.")
|
||||
|
||||
# let potential failures go to caller, caller should handle
|
||||
try:
|
||||
errors = []
|
||||
p = subprocess.Popen([*cl, "--output", str(output_path), "--gui-mode"], text=True, stdout=subprocess.PIPE)
|
||||
for line in iter(p.stdout.readline, ""):
|
||||
if line.startswith("#GUI#error") or line.startswith("#GUI#warning"):
|
||||
errors.append(line)
|
||||
if "progress" in line:
|
||||
progress(total=100, completed=int(line.strip()[14:-1]))
|
||||
return output_path, p.wait(), errors
|
||||
finally:
|
||||
if chapters_path:
|
||||
chapters_path.unlink()
|
||||
if delete:
|
||||
for track in self:
|
||||
track.delete()
|
||||
for attachment in self.attachments:
|
||||
if attachment.path and attachment.path.exists():
|
||||
attachment.path.unlink()
|
||||
|
||||
|
||||
__all__ = ("Tracks",)
|
||||
451
unshackle/core/tracks/video.py
Normal file
451
unshackle/core/tracks/video.py
Normal file
@@ -0,0 +1,451 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.utilities import FPS, get_boxes
|
||||
|
||||
|
||||
class Video(Track):
|
||||
class Codec(str, Enum):
|
||||
AVC = "H.264"
|
||||
HEVC = "H.265"
|
||||
VC1 = "VC-1"
|
||||
VP8 = "VP8"
|
||||
VP9 = "VP9"
|
||||
AV1 = "AV1"
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.value.lower().replace(".", "").replace("-", "")
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Video.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime in (
|
||||
"avc1",
|
||||
"avc2",
|
||||
"avc3",
|
||||
"dva1",
|
||||
"dvav", # Dolby Vision
|
||||
):
|
||||
return Video.Codec.AVC
|
||||
if mime in (
|
||||
"hev1",
|
||||
"hev2",
|
||||
"hev3",
|
||||
"hvc1",
|
||||
"hvc2",
|
||||
"hvc3",
|
||||
"dvh1",
|
||||
"dvhe", # Dolby Vision
|
||||
"lhv1",
|
||||
"lhe1", # Layered
|
||||
):
|
||||
return Video.Codec.HEVC
|
||||
if mime == "vc-1":
|
||||
return Video.Codec.VC1
|
||||
if mime in ("vp08", "vp8"):
|
||||
return Video.Codec.VP8
|
||||
if mime in ("vp09", "vp9"):
|
||||
return Video.Codec.VP9
|
||||
if mime == "av01":
|
||||
return Video.Codec.AV1
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Video Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Video.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
codec = codec.strip()
|
||||
mime = codec.split(".")[0]
|
||||
try:
|
||||
return Video.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Video Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Video.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith(("h264", "playready-h264")):
|
||||
return Video.Codec.AVC
|
||||
if profile.startswith("hevc"):
|
||||
return Video.Codec.HEVC
|
||||
if profile.startswith("vp9"):
|
||||
return Video.Codec.VP9
|
||||
if profile.startswith("av1"):
|
||||
return Video.Codec.AV1
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Video Codec")
|
||||
|
||||
class Range(str, Enum):
|
||||
SDR = "SDR" # No Dynamic Range
|
||||
HLG = "HLG" # https://en.wikipedia.org/wiki/Hybrid_log%E2%80%93gamma
|
||||
HDR10 = "HDR10" # https://en.wikipedia.org/wiki/HDR10
|
||||
HDR10P = "HDR10+" # https://en.wikipedia.org/wiki/HDR10%2B
|
||||
DV = "DV" # https://en.wikipedia.org/wiki/Dolby_Vision
|
||||
|
||||
@staticmethod
|
||||
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range:
|
||||
"""
|
||||
ISO/IEC 23001-8 Coding-independent code points to Video Range.
|
||||
|
||||
Sources:
|
||||
https://www.itu.int/rec/T-REC-H.Sup19-202104-I
|
||||
"""
|
||||
|
||||
class Primaries(Enum):
|
||||
Unspecified = 0
|
||||
BT_709 = 1
|
||||
BT_601_625 = 5
|
||||
BT_601_525 = 6
|
||||
BT_2020_and_2100 = 9
|
||||
SMPTE_ST_2113_and_EG_4321 = 12 # P3D65
|
||||
|
||||
class Transfer(Enum):
|
||||
Unspecified = 0
|
||||
BT_709 = 1
|
||||
BT_601 = 6
|
||||
BT_2020 = 14
|
||||
BT_2100 = 15
|
||||
BT_2100_PQ = 16
|
||||
BT_2100_HLG = 18
|
||||
|
||||
class Matrix(Enum):
|
||||
RGB = 0
|
||||
YCbCr_BT_709 = 1
|
||||
YCbCr_BT_601_625 = 5
|
||||
YCbCr_BT_601_525 = 6
|
||||
YCbCr_BT_2020_and_2100 = 9 # YCbCr BT.2100 shares the same CP
|
||||
ICtCp_BT_2100 = 14
|
||||
|
||||
if transfer == 5:
|
||||
# While not part of any standard, it is typically used as a PAL variant of Transfer.BT_601=6.
|
||||
# i.e. where Transfer 6 would be for BT.601-NTSC and Transfer 5 would be for BT.601-PAL.
|
||||
# The codebase is currently agnostic to either, so a manual conversion to 6 is done.
|
||||
transfer = 6
|
||||
|
||||
primaries = Primaries(primaries)
|
||||
transfer = Transfer(transfer)
|
||||
matrix = Matrix(matrix)
|
||||
|
||||
# primaries and matrix does not strictly correlate to a range
|
||||
|
||||
if (primaries, transfer, matrix) == (0, 0, 0):
|
||||
return Video.Range.SDR
|
||||
elif primaries in (Primaries.BT_601_625, Primaries.BT_601_525):
|
||||
return Video.Range.SDR
|
||||
elif transfer == Transfer.BT_2100_PQ:
|
||||
return Video.Range.HDR10
|
||||
elif transfer == Transfer.BT_2100_HLG:
|
||||
return Video.Range.HLG
|
||||
else:
|
||||
return Video.Range.SDR
|
||||
|
||||
@staticmethod
|
||||
def from_m3u_range_tag(tag: str) -> Optional[Video.Range]:
|
||||
tag = (tag or "").upper().replace('"', "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
if tag == "SDR":
|
||||
return Video.Range.SDR
|
||||
elif tag == "PQ":
|
||||
return Video.Range.HDR10 # technically could be any PQ-transfer range
|
||||
elif tag == "HLG":
|
||||
return Video.Range.HLG
|
||||
# for some reason there's no Dolby Vision info tag
|
||||
raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*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)
|
||||
|
||||
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.range = range_ or Video.Range.SDR
|
||||
|
||||
try:
|
||||
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Expected bitrate to be a number or float, {e}")
|
||||
|
||||
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:
|
||||
return " | ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
"VID",
|
||||
"[" + (", ".join(filter(bool, [self.codec.value if self.codec else None, self.range.name]))) + "]",
|
||||
str(self.language),
|
||||
", ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
" @ ".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,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def change_color_range(self, range_: int) -> None:
|
||||
"""Change the Video's Color Range to Limited (0) or Full (1)."""
|
||||
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.")
|
||||
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:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
filter_key = {Video.Codec.AVC: "h264_metadata", Video.Codec.HEVC: "hevc_metadata"}[self.codec]
|
||||
|
||||
original_path = self.path
|
||||
output_path = original_path.with_stem(f"{original_path.stem}_{['limited', 'full'][range_]}_range")
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"panic",
|
||||
"-i",
|
||||
original_path,
|
||||
"-codec",
|
||||
"copy",
|
||||
"-bsf:v",
|
||||
f"{filter_key}=video_full_range_flag={range_}",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
self.path = output_path
|
||||
original_path.unlink()
|
||||
|
||||
def ccextractor(
|
||||
self, track_id: Any, out_path: Union[Path, str], language: Language, original: bool = False
|
||||
) -> Optional[Subtitle]:
|
||||
"""Return a TextTrack object representing CC track extracted by CCExtractor."""
|
||||
if not self.path:
|
||||
raise ValueError("You must download the track first.")
|
||||
|
||||
if not binaries.CCExtractor:
|
||||
raise EnvironmentError("ccextractor executable was not found.")
|
||||
|
||||
# ccextractor often fails in weird ways unless we repack
|
||||
self.repackage()
|
||||
|
||||
out_path = Path(out_path)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
out_path.unlink(missing_ok=True)
|
||||
if not e.returncode == 10: # No captions found
|
||||
raise
|
||||
|
||||
if out_path.exists():
|
||||
cc_track = Subtitle(
|
||||
id_=track_id,
|
||||
url="", # doesn't need to be downloaded
|
||||
codec=Subtitle.Codec.SubRip,
|
||||
language=language,
|
||||
is_original_lang=original,
|
||||
cc=True,
|
||||
)
|
||||
cc_track.path = out_path
|
||||
return cc_track
|
||||
|
||||
return None
|
||||
|
||||
def extract_c608(self) -> list[Subtitle]:
|
||||
"""
|
||||
Extract Apple-Style c608 box (CEA-608) subtitle using ccextractor.
|
||||
|
||||
This isn't much more than a wrapper to the track.ccextractor function.
|
||||
All this does, is actually check if a c608 box exists and only if so
|
||||
does it actually call ccextractor.
|
||||
|
||||
Even though there is a possibility of more than one c608 box, only one
|
||||
can actually be extracted. Not only that but it's very possible this
|
||||
needs to be done before any decryption as the decryption may destroy
|
||||
some of the metadata.
|
||||
|
||||
TODO: Need a test file with more than one c608 box to add support for
|
||||
more than one CEA-608 extraction.
|
||||
"""
|
||||
if not self.path:
|
||||
raise ValueError("You must download the track first.")
|
||||
with self.path.open("rb") as f:
|
||||
# assuming 20KB is enough to contain the c608 box.
|
||||
# ffprobe will fail, so a c608 box check must be done.
|
||||
c608_count = len(list(get_boxes(f.read(20000), b"c608")))
|
||||
if c608_count > 0:
|
||||
# TODO: Figure out the real language, it might be different
|
||||
# CEA-608 boxes doesnt seem to carry language information :(
|
||||
# TODO: Figure out if the CC language is original lang or not.
|
||||
# Will need to figure out above first to do so.
|
||||
track_id = f"ccextractor-{self.id}"
|
||||
cc_lang = self.language
|
||||
cc_track = self.ccextractor(
|
||||
track_id=track_id,
|
||||
out_path=config.directories.temp / config.filenames.subtitle.format(id=track_id, language=cc_lang),
|
||||
language=cc_lang,
|
||||
original=False,
|
||||
)
|
||||
if not cc_track:
|
||||
return []
|
||||
return [cc_track]
|
||||
return []
|
||||
|
||||
def remove_eia_cc(self) -> bool:
|
||||
"""
|
||||
Remove EIA-CC data from Bitstream while keeping SEI data.
|
||||
|
||||
This works by removing all NAL Unit's with the Type of 6 from the bistream
|
||||
and then re-adding SEI data (effectively a new NAL Unit with just the SEI data).
|
||||
Only bitstreams with x264 encoding information is currently supported due to the
|
||||
obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("Cannot clean a Track that has not been downloaded.")
|
||||
|
||||
if not binaries.FFMPEG:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
log = logging.getLogger("x264-clean")
|
||||
log.info("Removing EIA-CC from Video Track with FFMPEG")
|
||||
|
||||
with open(self.path, "rb") as f:
|
||||
file = f.read(60000)
|
||||
|
||||
x264 = re.search(rb"(.{16})(x264)", file)
|
||||
if not x264:
|
||||
log.info(" - No x264 encode settings were found, unsupported...")
|
||||
return False
|
||||
|
||||
uuid = x264.group(1).hex()
|
||||
i = file.index(b"x264")
|
||||
encoding_settings = file[i : i + file[i:].index(b"\x00")].replace(b":", rb"\\:").replace(b",", rb"\,").decode()
|
||||
|
||||
original_path = self.path
|
||||
cleaned_path = original_path.with_suffix(f".cleaned{original_path.suffix}")
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"panic",
|
||||
"-i",
|
||||
original_path,
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-fflags",
|
||||
"bitexact",
|
||||
"-bsf:v",
|
||||
f"filter_units=remove_types=6,h264_metadata=sei_user_data={uuid}+{encoding_settings}",
|
||||
"-codec",
|
||||
"copy",
|
||||
str(cleaned_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
log.info(" + Removed")
|
||||
|
||||
self.path = cleaned_path
|
||||
original_path.unlink()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ("Video",)
|
||||
354
unshackle/core/utilities.py
Normal file
354
unshackle/core/utilities.py
Normal file
@@ -0,0 +1,354 @@
|
||||
import ast
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Optional, Sequence, Union
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
import chardet
|
||||
import requests
|
||||
from construct import ValidationError
|
||||
from langcodes import Language, closest_match
|
||||
from pymp4.parser import Box
|
||||
from unidecode import unidecode
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import LANGUAGE_MAX_DISTANCE
|
||||
|
||||
|
||||
def rotate_log_file(log_path: Path, keep: int = 20) -> Path:
|
||||
"""
|
||||
Update Log Filename and delete old log files.
|
||||
It keeps only the 20 newest logs by default.
|
||||
"""
|
||||
if not log_path:
|
||||
raise ValueError("A log path must be provided")
|
||||
|
||||
try:
|
||||
log_path.relative_to(Path("")) # file name only
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
log_path = config.directories.logs / log_path
|
||||
|
||||
log_path = log_path.parent / log_path.name.format_map(
|
||||
defaultdict(str, name="root", time=datetime.now().strftime("%Y%m%d-%H%M%S"))
|
||||
)
|
||||
|
||||
if log_path.parent.exists():
|
||||
log_files = [x for x in log_path.parent.iterdir() if x.suffix == log_path.suffix]
|
||||
for log_file in log_files[::-1][keep - 1 :]:
|
||||
# keep n newest files and delete the rest
|
||||
log_file.unlink()
|
||||
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return log_path
|
||||
|
||||
|
||||
def import_module_by_path(path: Path) -> ModuleType:
|
||||
"""Import a Python file by Path as a Module."""
|
||||
if not path:
|
||||
raise ValueError("Path must be provided")
|
||||
if not isinstance(path, Path):
|
||||
raise TypeError(f"Expected path to be a {Path}, not {path!r}")
|
||||
if not path.exists():
|
||||
raise ValueError("Path does not exist")
|
||||
|
||||
# compute package hierarchy for relative import support
|
||||
if path.is_relative_to(config.directories.core_dir):
|
||||
name = []
|
||||
_path = path.parent
|
||||
while _path.stem != config.directories.core_dir.stem:
|
||||
name.append(_path.stem)
|
||||
_path = _path.parent
|
||||
name = ".".join([config.directories.core_dir.stem] + name[::-1])
|
||||
else:
|
||||
# is outside the src package
|
||||
if str(path.parent.parent) not in sys.path:
|
||||
sys.path.insert(1, str(path.parent.parent))
|
||||
name = path.parent.stem
|
||||
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def sanitize_filename(filename: str, spacer: str = ".") -> str:
|
||||
"""
|
||||
Sanitize a string to be filename safe.
|
||||
|
||||
The spacer is safer to be a '.' for older DDL and p2p sharing spaces.
|
||||
This includes web-served content via direct links and such.
|
||||
"""
|
||||
# replace all non-ASCII characters with ASCII equivalents
|
||||
filename = unidecode(filename)
|
||||
|
||||
# remove or replace further characters as needed
|
||||
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn") # hidden characters
|
||||
filename = filename.replace("/", " & ").replace(";", " & ") # e.g. multi-episode filenames
|
||||
filename = re.sub(r"[:; ]", spacer, filename) # structural chars to (spacer)
|
||||
filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars
|
||||
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def is_close_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
|
||||
"""Check if a language is a close match to any of the provided languages."""
|
||||
languages = [x for x in languages if x]
|
||||
if not languages:
|
||||
return False
|
||||
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE
|
||||
|
||||
|
||||
def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
|
||||
"""
|
||||
Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find.
|
||||
|
||||
This function searches through binary MP4 data to find and parse specific box types.
|
||||
The MP4/ISOBMFF box format consists of:
|
||||
- 4 bytes: size of the box (including size and type fields)
|
||||
- 4 bytes: box type identifier (e.g., 'moov', 'trak', 'pssh')
|
||||
- Remaining bytes: box data
|
||||
|
||||
The function uses slicing to directly locate the requested box type in the data
|
||||
rather than recursively traversing the box hierarchy. This is efficient when
|
||||
looking for specific box types regardless of their position in the hierarchy.
|
||||
|
||||
Parameters:
|
||||
data: Binary data containing MP4/ISOBMFF boxes
|
||||
box_type: 4-byte identifier of the box type to find (e.g., b'pssh')
|
||||
as_bytes: If True, returns the box as bytes, otherwise returns parsed box object
|
||||
|
||||
Yields:
|
||||
Box objects of the requested type found in the data
|
||||
|
||||
Notes:
|
||||
- For each box found, the function updates the search offset to skip past
|
||||
the current box to avoid finding the same box multiple times
|
||||
- The function handles validation errors for certain box types (e.g., tenc)
|
||||
- The size field is located 4 bytes before the box type identifier
|
||||
"""
|
||||
# using slicing to get to the wanted box is done because parsing the entire box and recursively
|
||||
# scanning through each box and its children often wouldn't scan far enough to reach the wanted box.
|
||||
# since it doesn't care what child box the wanted box is from, this works fine.
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
raise ValueError("data must be bytes")
|
||||
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
try:
|
||||
index = data[offset:].index(box_type)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
pos = offset + index
|
||||
|
||||
if pos < 4:
|
||||
offset = pos + len(box_type)
|
||||
continue
|
||||
|
||||
box_start = pos - 4
|
||||
|
||||
try:
|
||||
box = Box.parse(data[box_start:])
|
||||
if as_bytes:
|
||||
box = Box.build(box)
|
||||
|
||||
yield box
|
||||
|
||||
box_size = len(Box.build(box))
|
||||
offset = box_start + box_size
|
||||
|
||||
except IOError:
|
||||
break
|
||||
except ValidationError as e:
|
||||
if box_type == b"tenc":
|
||||
offset = pos + len(box_type)
|
||||
continue
|
||||
raise e
|
||||
|
||||
|
||||
def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None) -> str:
|
||||
"""
|
||||
Convert a string to title case using AP/APA style.
|
||||
Based on https://github.com/words/ap-style-title-case
|
||||
|
||||
Parameters:
|
||||
text: The text string to title case with AP/APA style.
|
||||
keep_spaces: To keep the original whitespace, or to just use a normal space.
|
||||
This would only be needed if you have special whitespace between words.
|
||||
stop_words: Override the default stop words with your own ones.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
if not stop_words:
|
||||
stop_words = (
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"at",
|
||||
"but",
|
||||
"by",
|
||||
"for",
|
||||
"in",
|
||||
"nor",
|
||||
"of",
|
||||
"on",
|
||||
"or",
|
||||
"so",
|
||||
"the",
|
||||
"to",
|
||||
"up",
|
||||
"yet",
|
||||
)
|
||||
|
||||
splitter = re.compile(r"(\s+|[-‑–—])")
|
||||
words = splitter.split(text)
|
||||
|
||||
return "".join(
|
||||
[
|
||||
[" ", word][keep_spaces]
|
||||
if re.match(r"\s+", word)
|
||||
else word
|
||||
if splitter.match(word)
|
||||
else word.lower()
|
||||
if i != 0 and i != len(words) - 1 and word.lower() in stop_words
|
||||
else word.capitalize()
|
||||
for i, word in enumerate(words)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_ip_info(session: Optional[requests.Session] = None) -> dict:
|
||||
"""
|
||||
Use ipinfo.io to get IP location information.
|
||||
|
||||
If you provide a Requests Session with a Proxy, that proxies IP information
|
||||
is what will be returned.
|
||||
"""
|
||||
return (session or requests.Session()).get("https://ipinfo.io/json").json()
|
||||
|
||||
|
||||
def time_elapsed_since(start: float) -> str:
|
||||
"""
|
||||
Get time elapsed since a timestamp as a string.
|
||||
E.g., `1h56m2s`, `15m12s`, `0m55s`, e.t.c.
|
||||
"""
|
||||
elapsed = int(time.time() - start)
|
||||
|
||||
minutes, seconds = divmod(elapsed, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
|
||||
time_string = f"{minutes:d}m{seconds:d}s"
|
||||
if hours:
|
||||
time_string = f"{hours:d}h{time_string}"
|
||||
|
||||
return time_string
|
||||
|
||||
|
||||
def try_ensure_utf8(data: bytes) -> bytes:
|
||||
"""
|
||||
Try to ensure that the given data is encoded in UTF-8.
|
||||
|
||||
Parameters:
|
||||
data: Input data that may or may not yet be UTF-8 or another encoding.
|
||||
|
||||
Returns the input data encoded in UTF-8 if successful. If unable to detect the
|
||||
encoding of the input data, then the original data is returned as-received.
|
||||
"""
|
||||
try:
|
||||
data.decode("utf8")
|
||||
return data
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
# CP-1252 is a superset of latin1 but has gaps. Replace unknown
|
||||
# characters instead of failing on them.
|
||||
return data.decode("cp1252", errors="replace").encode("utf8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
# last ditch effort to detect encoding
|
||||
detection_result = chardet.detect(data)
|
||||
if not detection_result["encoding"]:
|
||||
return data
|
||||
return data.decode(detection_result["encoding"]).encode("utf8")
|
||||
except UnicodeDecodeError:
|
||||
return data
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
"""
|
||||
Get an available port to use between a-b (inclusive).
|
||||
|
||||
The port is freed as soon as this has returned, therefore, it
|
||||
is possible for the port to be taken before you try to use it.
|
||||
"""
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
|
||||
"""
|
||||
Get a URL or Path file extension/suffix.
|
||||
|
||||
Note: The returned value will begin with `.`.
|
||||
"""
|
||||
if isinstance(value, ParseResult):
|
||||
value_parsed = value
|
||||
elif isinstance(value, (str, Path)):
|
||||
value_parsed = urlparse(str(value))
|
||||
else:
|
||||
raise TypeError(f"Expected {str}, {Path}, or {ParseResult}, got {type(value)}")
|
||||
|
||||
if value_parsed.path:
|
||||
ext = os.path.splitext(value_parsed.path)[1]
|
||||
if ext and 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):
|
||||
def visit_BinOp(self, node: ast.BinOp) -> float:
|
||||
if isinstance(node.op, ast.Div):
|
||||
return self.visit(node.left) / self.visit(node.right)
|
||||
raise ValueError(f"Invalid operation: {node.op}")
|
||||
|
||||
def visit_Num(self, node: ast.Num) -> complex:
|
||||
return node.n
|
||||
|
||||
def visit_Expr(self, node: ast.Expr) -> float:
|
||||
return self.visit(node.value)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, expr: str) -> float:
|
||||
return cls().visit(ast.parse(expr).body[0])
|
||||
0
unshackle/core/utils/__init__.py
Normal file
0
unshackle/core/utils/__init__.py
Normal file
169
unshackle/core/utils/click_types.py
Normal file
169
unshackle/core/utils/click_types.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import re
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
|
||||
|
||||
class ContextData:
|
||||
def __init__(self, config: dict, cdm: WidevineCdm, proxy_providers: list, profile: Optional[str] = None):
|
||||
self.config = config
|
||||
self.cdm = cdm
|
||||
self.proxy_providers = proxy_providers
|
||||
self.profile = profile
|
||||
|
||||
|
||||
class SeasonRange(click.ParamType):
|
||||
name = "ep_range"
|
||||
|
||||
MIN_EPISODE = 0
|
||||
MAX_EPISODE = 999
|
||||
|
||||
def parse_tokens(self, *tokens: str) -> list[str]:
|
||||
"""
|
||||
Parse multiple tokens or ranged tokens as '{s}x{e}' strings.
|
||||
|
||||
Supports exclusioning by putting a `-` before the token.
|
||||
|
||||
Example:
|
||||
>>> sr = SeasonRange()
|
||||
>>> sr.parse_tokens("S01E01")
|
||||
["1x1"]
|
||||
>>> sr.parse_tokens("S02E01", "S02E03-S02E05")
|
||||
["2x1", "2x3", "2x4", "2x5"]
|
||||
>>> sr.parse_tokens("S01-S05", "-S03", "-S02E01")
|
||||
["1x0", "1x1", ..., "2x0", (...), "2x2", (...), "4x0", ..., "5x0", ...]
|
||||
"""
|
||||
if len(tokens) == 0:
|
||||
return []
|
||||
computed: list = []
|
||||
exclusions: list = []
|
||||
for token in tokens:
|
||||
exclude = token.startswith("-")
|
||||
if exclude:
|
||||
token = token[1:]
|
||||
parsed = [
|
||||
re.match(r"^S(?P<season>\d+)(E(?P<episode>\d+))?$", x, re.IGNORECASE) for x in re.split(r"[:-]", token)
|
||||
]
|
||||
if len(parsed) > 2:
|
||||
self.fail(f"Invalid token, only a left and right range is acceptable: {token}")
|
||||
if len(parsed) == 1:
|
||||
parsed.append(parsed[0])
|
||||
if any(x is None for x in parsed):
|
||||
self.fail(f"Invalid token, syntax error occurred: {token}")
|
||||
from_season, from_episode = [
|
||||
int(v) if v is not None else self.MIN_EPISODE
|
||||
for k, v in parsed[0].groupdict().items()
|
||||
if parsed[0] # type: ignore[union-attr]
|
||||
]
|
||||
to_season, to_episode = [
|
||||
int(v) if v is not None else self.MAX_EPISODE
|
||||
for k, v in parsed[1].groupdict().items()
|
||||
if parsed[1] # type: ignore[union-attr]
|
||||
]
|
||||
if from_season > to_season:
|
||||
self.fail(f"Invalid range, left side season cannot be bigger than right side season: {token}")
|
||||
if from_season == to_season and from_episode > to_episode:
|
||||
self.fail(f"Invalid range, left side episode cannot be bigger than right side episode: {token}")
|
||||
for s in range(from_season, to_season + 1):
|
||||
for e in range(
|
||||
from_episode if s == from_season else 0, (self.MAX_EPISODE if s < to_season else to_episode) + 1
|
||||
):
|
||||
(computed if not exclude else exclusions).append(f"{s}x{e}")
|
||||
for exclusion in exclusions:
|
||||
if exclusion in computed:
|
||||
computed.remove(exclusion)
|
||||
return list(set(computed))
|
||||
|
||||
def convert(
|
||||
self, value: str, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
|
||||
) -> list[str]:
|
||||
return self.parse_tokens(*re.split(r"\s*[,;]\s*", value))
|
||||
|
||||
|
||||
class LanguageRange(click.ParamType):
|
||||
name = "lang_range"
|
||||
|
||||
def convert(
|
||||
self, value: Union[str, list], param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
|
||||
) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if not value:
|
||||
return []
|
||||
return re.split(r"\s*[,;]\s*", value)
|
||||
|
||||
|
||||
class QualityList(click.ParamType):
|
||||
name = "quality_list"
|
||||
|
||||
def convert(
|
||||
self, value: Union[str, list[str]], param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
|
||||
) -> list[int]:
|
||||
if not value:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
value = value.split(",")
|
||||
resolutions = []
|
||||
for resolution in value:
|
||||
try:
|
||||
resolutions.append(int(resolution.lower().rstrip("p")))
|
||||
except TypeError:
|
||||
self.fail(
|
||||
f"Expected string for int() conversion, got {resolution!r} of type {type(resolution).__name__}",
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
except ValueError:
|
||||
self.fail(f"{resolution!r} is not a valid integer", param, ctx)
|
||||
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()
|
||||
LANGUAGE_RANGE = LanguageRange()
|
||||
QUALITY_LIST = QualityList()
|
||||
51
unshackle/core/utils/collections.py
Normal file
51
unshackle/core/utils/collections.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import itertools
|
||||
from typing import Any, Iterable, Iterator, Sequence, Tuple, Type, Union
|
||||
|
||||
|
||||
def as_lists(*args: Any) -> Iterator[Any]:
|
||||
"""Converts any input objects to list objects."""
|
||||
for item in args:
|
||||
yield item if isinstance(item, list) else [item]
|
||||
|
||||
|
||||
def as_list(*args: Any) -> list:
|
||||
"""
|
||||
Convert any input objects to a single merged list object.
|
||||
|
||||
Example:
|
||||
>>> as_list('foo', ['buzz', 'bizz'], 'bazz', 'bozz', ['bar'], ['bur'])
|
||||
['foo', 'buzz', 'bizz', 'bazz', 'bozz', 'bar', 'bur']
|
||||
"""
|
||||
return list(itertools.chain.from_iterable(as_lists(*args)))
|
||||
|
||||
|
||||
def flatten(items: Any, ignore_types: Union[Type, Tuple[Type, ...]] = str) -> Iterator:
|
||||
"""
|
||||
Flattens items recursively.
|
||||
|
||||
Example:
|
||||
>>> list(flatten(["foo", [["bar", ["buzz", [""]], "bee"]]]))
|
||||
['foo', 'bar', 'buzz', '', 'bee']
|
||||
>>> list(flatten("foo"))
|
||||
['foo']
|
||||
>>> list(flatten({1}, set))
|
||||
[{1}]
|
||||
"""
|
||||
if isinstance(items, (Iterable, Sequence)) and not isinstance(items, ignore_types):
|
||||
for i in items:
|
||||
yield from flatten(i, ignore_types)
|
||||
else:
|
||||
yield items
|
||||
|
||||
|
||||
def merge_dict(source: dict, destination: dict) -> None:
|
||||
"""Recursively merge Source into Destination in-place."""
|
||||
if not source:
|
||||
return
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict):
|
||||
# get node or create one
|
||||
node = destination.setdefault(key, {})
|
||||
merge_dict(value, node)
|
||||
else:
|
||||
destination[key] = value
|
||||
30
unshackle/core/utils/gen_esn.py
Normal file
30
unshackle/core/utils/gen_esn.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
log = logging.getLogger("NF-ESN")
|
||||
|
||||
|
||||
def chrome_esn_generator():
|
||||
ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30))
|
||||
esn_file = ".esn"
|
||||
|
||||
def gen_file():
|
||||
with open(esn_file, "w") as file:
|
||||
file.write(f"NFCDIE-03-{ESN_GEN}")
|
||||
|
||||
if not os.path.isfile(esn_file):
|
||||
log.warning("Generating a new Chrome ESN")
|
||||
gen_file()
|
||||
|
||||
file_datetime = datetime.fromtimestamp(os.path.getmtime(esn_file))
|
||||
time_diff = datetime.now() - file_datetime
|
||||
if time_diff > timedelta(hours=6):
|
||||
log.warning("Old ESN detected, Generating a new Chrome ESN")
|
||||
gen_file()
|
||||
|
||||
with open(esn_file, "r") as f:
|
||||
esn = f.read()
|
||||
|
||||
return esn
|
||||
24
unshackle/core/utils/osenvironment.py
Normal file
24
unshackle/core/utils/osenvironment.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import platform
|
||||
|
||||
|
||||
def get_os_arch(name: str) -> str:
|
||||
"""Builds a name-os-arch based on the input name, system, architecture."""
|
||||
os_name = platform.system().lower()
|
||||
os_arch = platform.machine().lower()
|
||||
|
||||
# Map platform.system() output to desired OS name
|
||||
if os_name == "windows":
|
||||
os_name = "win"
|
||||
elif os_name == "darwin":
|
||||
os_name = "osx"
|
||||
else:
|
||||
os_name = "linux"
|
||||
|
||||
# Map platform.machine() output to desired architecture
|
||||
if os_arch in ["x86_64", "amd64"]:
|
||||
os_arch = "x64"
|
||||
elif os_arch == "arm64":
|
||||
os_arch = "arm64"
|
||||
|
||||
# Construct the dependency name in the desired format using the input name
|
||||
return f"{name}-{os_name}-{os_arch}"
|
||||
77
unshackle/core/utils/sslciphers.py
Normal file
77
unshackle/core/utils/sslciphers.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import ssl
|
||||
from typing import Optional
|
||||
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
|
||||
class SSLCiphers(HTTPAdapter):
|
||||
"""
|
||||
Custom HTTP Adapter to change the TLS Cipher set and security requirements.
|
||||
|
||||
Security Level may optionally be provided. A level above 0 must be used at all times.
|
||||
A list of Security Levels and their security is listed below. Usually 2 is used by default.
|
||||
Do not set the Security level via @SECLEVEL in the cipher list.
|
||||
|
||||
Level 0:
|
||||
Everything is permitted. This retains compatibility with previous versions of OpenSSL.
|
||||
|
||||
Level 1:
|
||||
The security level corresponds to a minimum of 80 bits of security. Any parameters
|
||||
offering below 80 bits of security are excluded. As a result RSA, DSA and DH keys
|
||||
shorter than 1024 bits and ECC keys shorter than 160 bits are prohibited. All export
|
||||
cipher suites are prohibited since they all offer less than 80 bits of security. SSL
|
||||
version 2 is prohibited. Any cipher suite using MD5 for the MAC is also prohibited.
|
||||
|
||||
Level 2:
|
||||
Security level set to 112 bits of security. As a result RSA, DSA and DH keys shorter
|
||||
than 2048 bits and ECC keys shorter than 224 bits are prohibited. In addition to the
|
||||
level 1 exclusions any cipher suite using RC4 is also prohibited. SSL version 3 is
|
||||
also not allowed. Compression is disabled.
|
||||
|
||||
Level 3:
|
||||
Security level set to 128 bits of security. As a result RSA, DSA and DH keys shorter
|
||||
than 3072 bits and ECC keys shorter than 256 bits are prohibited. In addition to the
|
||||
level 2 exclusions cipher suites not offering forward secrecy are prohibited. TLS
|
||||
versions below 1.1 are not permitted. Session tickets are disabled.
|
||||
|
||||
Level 4:
|
||||
Security level set to 192 bits of security. As a result RSA, DSA and DH keys shorter
|
||||
than 7680 bits and ECC keys shorter than 384 bits are prohibited. Cipher suites using
|
||||
SHA1 for the MAC are prohibited. TLS versions below 1.2 are not permitted.
|
||||
|
||||
Level 5:
|
||||
Security level set to 256 bits of security. As a result RSA, DSA and DH keys shorter
|
||||
than 15360 bits and ECC keys shorter than 512 bits are prohibited.
|
||||
"""
|
||||
|
||||
def __init__(self, cipher_list: Optional[str] = None, security_level: int = 0, *args, **kwargs):
|
||||
if cipher_list:
|
||||
if not isinstance(cipher_list, str):
|
||||
raise TypeError(f"Expected cipher_list to be a str, not {cipher_list!r}")
|
||||
if "@SECLEVEL" in cipher_list:
|
||||
raise ValueError("You must not specify the Security Level manually in the cipher list.")
|
||||
if not isinstance(security_level, int):
|
||||
raise TypeError(f"Expected security_level to be an int, not {security_level!r}")
|
||||
if security_level not in range(6):
|
||||
raise ValueError(f"The security_level must be a value between 0 and 5, not {security_level}")
|
||||
|
||||
if not cipher_list:
|
||||
# cpython's default cipher list differs to Python-requests cipher list
|
||||
cipher_list = "DEFAULT"
|
||||
|
||||
cipher_list += f":@SECLEVEL={security_level}"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False # For some reason this is needed to avoid a verification error
|
||||
ctx.set_ciphers(cipher_list)
|
||||
|
||||
self._ssl_context = ctx
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ssl_context
|
||||
return super().init_poolmanager(*args, **kwargs)
|
||||
|
||||
def proxy_manager_for(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ssl_context
|
||||
return super().proxy_manager_for(*args, **kwargs)
|
||||
25
unshackle/core/utils/subprocess.py
Normal file
25
unshackle/core/utils/subprocess.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from unshackle.core import binaries
|
||||
|
||||
|
||||
def ffprobe(uri: Union[bytes, Path]) -> dict:
|
||||
"""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 = [binaries.FFProbe, "-v", "quiet", "-of", "json", "-show_streams"]
|
||||
if isinstance(uri, Path):
|
||||
args.extend(
|
||||
["-f", "lavfi", "-i", "movie={}[out+subcc]".format(str(uri).replace("\\", "/").replace(":", "\\\\:"))]
|
||||
)
|
||||
elif isinstance(uri, bytes):
|
||||
args.append("pipe:")
|
||||
try:
|
||||
ff = subprocess.run(args, input=uri if isinstance(uri, bytes) else None, check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
return {}
|
||||
return json.loads(ff.stdout.decode("utf8"))
|
||||
279
unshackle/core/utils/tags.py
Normal file
279
unshackle/core/utils/tags.py
Normal file
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.titles.episode import Episode
|
||||
from unshackle.core.titles.movie import Movie
|
||||
from unshackle.core.titles.title import Title
|
||||
|
||||
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
|
||||
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
|
||||
HEADERS = {"User-Agent": "unshackle-tags/1.0"}
|
||||
|
||||
|
||||
log = logging.getLogger("TAGS")
|
||||
|
||||
|
||||
def _api_key() -> Optional[str]:
|
||||
return config.tmdb_api_key or os.getenv("TMDB_API_KEY")
|
||||
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return STRIP_RE.sub("", s).lower()
|
||||
|
||||
|
||||
def _strip_year(s: str) -> str:
|
||||
return YEAR_RE.sub("", s).strip()
|
||||
|
||||
|
||||
def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool:
|
||||
"""Return True if ``a`` and ``b`` are a close match."""
|
||||
|
||||
ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio()
|
||||
return ratio >= threshold
|
||||
|
||||
|
||||
def search_tmdb(title: str, year: Optional[int], kind: str) -> Tuple[Optional[int], Optional[str]]:
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
return None, None
|
||||
|
||||
search_title = _strip_year(title)
|
||||
log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year)
|
||||
|
||||
params = {"api_key": api_key, "query": search_title}
|
||||
if year is not None:
|
||||
params["year" if kind == "movie" else "first_air_date_year"] = year
|
||||
|
||||
r = requests.get(
|
||||
f"https://api.themoviedb.org/3/search/{kind}",
|
||||
params=params,
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
js = r.json()
|
||||
results = js.get("results") or []
|
||||
log.debug("TMDB returned %d results", len(results))
|
||||
if not results:
|
||||
return None, None
|
||||
|
||||
best_ratio = 0.0
|
||||
best_id: Optional[int] = None
|
||||
best_title: Optional[str] = None
|
||||
for result in results:
|
||||
candidates = [
|
||||
result.get("title"),
|
||||
result.get("name"),
|
||||
result.get("original_title"),
|
||||
result.get("original_name"),
|
||||
]
|
||||
candidates = [c for c in candidates if c] # Filter out None/empty values
|
||||
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
# Find the best matching candidate from all available titles
|
||||
for candidate in candidates:
|
||||
ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_id = result.get("id")
|
||||
best_title = candidate
|
||||
log.debug(
|
||||
"Best candidate ratio %.2f for %r (ID %s)",
|
||||
best_ratio,
|
||||
best_title,
|
||||
best_id,
|
||||
)
|
||||
|
||||
if best_id is not None:
|
||||
return best_id, best_title
|
||||
|
||||
first = results[0]
|
||||
return first.get("id"), first.get("title") or first.get("name")
|
||||
|
||||
|
||||
def get_title(tmdb_id: int, kind: str) -> Optional[str]:
|
||||
"""Fetch the name/title of a TMDB entry by ID."""
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
|
||||
params={"api_key": api_key},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
log.debug("Failed to fetch TMDB title: %s", exc)
|
||||
return None
|
||||
|
||||
js = r.json()
|
||||
return js.get("title") or js.get("name")
|
||||
|
||||
|
||||
def get_year(tmdb_id: int, kind: str) -> Optional[int]:
|
||||
"""Fetch the release year of a TMDB entry by ID."""
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
|
||||
params={"api_key": api_key},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
log.debug("Failed to fetch TMDB year: %s", exc)
|
||||
return None
|
||||
|
||||
js = r.json()
|
||||
date = js.get("release_date") or js.get("first_air_date")
|
||||
if date and len(date) >= 4 and date[:4].isdigit():
|
||||
return int(date[:4])
|
||||
return None
|
||||
|
||||
|
||||
def external_ids(tmdb_id: int, kind: str) -> dict:
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
return {}
|
||||
url = f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids"
|
||||
log.debug("Fetching external IDs for %s %s", kind, tmdb_id)
|
||||
r = requests.get(
|
||||
url,
|
||||
params={"api_key": api_key},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
js = r.json()
|
||||
log.debug("External IDs response: %s", js)
|
||||
return js
|
||||
|
||||
|
||||
def _apply_tags(path: Path, tags: dict[str, str]) -> None:
|
||||
if not tags:
|
||||
return
|
||||
mkvpropedit = shutil.which("mkvpropedit")
|
||||
if not mkvpropedit:
|
||||
log.debug("mkvpropedit not found on PATH; skipping tags")
|
||||
return
|
||||
log.debug("Applying tags to %s: %s", path, tags)
|
||||
xml_lines = ["<?xml version='1.0' encoding='UTF-8'?>", "<Tags>", " <Tag>", " <Targets/>"]
|
||||
for name, value in tags.items():
|
||||
xml_lines.append(f" <Simple><Name>{name}</Name><String>{value}</String></Simple>")
|
||||
xml_lines.extend([" </Tag>", "</Tags>"])
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as f:
|
||||
f.write("\n".join(xml_lines))
|
||||
tmp_path = Path(f.name)
|
||||
try:
|
||||
subprocess.run(
|
||||
[mkvpropedit, str(path), "--tags", f"global:{tmp_path}"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
log.debug("Tags applied via mkvpropedit")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> None:
|
||||
log.debug("Tagging file %s with title %r", path, title)
|
||||
standard_tags: dict[str, str] = {}
|
||||
custom_tags: dict[str, str] = {}
|
||||
# To add custom information to the tags
|
||||
# custom_tags["Text to the left side"] = "Text to the right side"
|
||||
|
||||
if config.tag:
|
||||
custom_tags["Group"] = config.tag
|
||||
description = getattr(title, "description", None)
|
||||
if description:
|
||||
if len(description) > 255:
|
||||
truncated = description[:255]
|
||||
if " " in truncated:
|
||||
truncated = truncated.rsplit(" ", 1)[0]
|
||||
description = truncated + "..."
|
||||
custom_tags["Description"] = description
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
log.debug("No TMDB API key set; applying basic tags only")
|
||||
_apply_tags(path, custom_tags)
|
||||
return
|
||||
|
||||
if isinstance(title, Movie):
|
||||
kind = "movie"
|
||||
name = title.name
|
||||
year = title.year
|
||||
elif isinstance(title, Episode):
|
||||
kind = "tv"
|
||||
name = title.title
|
||||
year = title.year
|
||||
else:
|
||||
_apply_tags(path, custom_tags)
|
||||
return
|
||||
|
||||
tmdb_title: Optional[str] = None
|
||||
if tmdb_id is None:
|
||||
tmdb_id, tmdb_title = search_tmdb(name, year, kind)
|
||||
log.debug("Search result: %r (ID %s)", tmdb_title, tmdb_id)
|
||||
if not tmdb_id or not tmdb_title or not fuzzy_match(tmdb_title, name):
|
||||
log.debug("TMDB search did not match; skipping external ID lookup")
|
||||
_apply_tags(path, custom_tags)
|
||||
return
|
||||
|
||||
tmdb_url = f"https://www.themoviedb.org/{'movie' if kind == 'movie' else 'tv'}/{tmdb_id}"
|
||||
standard_tags["TMDB"] = tmdb_url
|
||||
try:
|
||||
ids = external_ids(tmdb_id, kind)
|
||||
except requests.RequestException as exc:
|
||||
log.debug("Failed to fetch external IDs: %s", exc)
|
||||
ids = {}
|
||||
else:
|
||||
log.debug("External IDs found: %s", ids)
|
||||
|
||||
imdb_id = ids.get("imdb_id")
|
||||
if imdb_id:
|
||||
standard_tags["IMDB"] = f"https://www.imdb.com/title/{imdb_id}"
|
||||
tvdb_id = ids.get("tvdb_id")
|
||||
if tvdb_id:
|
||||
tvdb_prefix = "movies" if kind == "movie" else "series"
|
||||
standard_tags["TVDB"] = f"https://thetvdb.com/dereferrer/{tvdb_prefix}/{tvdb_id}"
|
||||
|
||||
merged_tags = {
|
||||
**custom_tags,
|
||||
**standard_tags,
|
||||
}
|
||||
_apply_tags(path, merged_tags)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"search_tmdb",
|
||||
"get_title",
|
||||
"get_year",
|
||||
"external_ids",
|
||||
"tag_file",
|
||||
"fuzzy_match",
|
||||
]
|
||||
192
unshackle/core/utils/webvtt.py
Normal file
192
unshackle/core/utils/webvtt.py
Normal file
@@ -0,0 +1,192 @@
|
||||
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
|
||||
|
||||
# Commeted to fix DSNP subs being out of sync and mistimed.
|
||||
# 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)
|
||||
24
unshackle/core/utils/xml.py
Normal file
24
unshackle/core/utils/xml.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import Union
|
||||
|
||||
from lxml import etree
|
||||
from lxml.etree import ElementTree
|
||||
|
||||
|
||||
def load_xml(xml: Union[str, bytes]) -> ElementTree:
|
||||
"""Safely parse XML data to an ElementTree, without namespaces in tags."""
|
||||
if not isinstance(xml, bytes):
|
||||
xml = xml.encode("utf8")
|
||||
root = etree.fromstring(xml)
|
||||
for elem in root.getiterator():
|
||||
if not hasattr(elem.tag, "find"):
|
||||
# e.g. comment elements
|
||||
continue
|
||||
elem.tag = etree.QName(elem).localname
|
||||
for name, value in elem.attrib.items():
|
||||
local_name = etree.QName(name).localname
|
||||
if local_name == name:
|
||||
continue
|
||||
del elem.attrib[name]
|
||||
elem.attrib[local_name] = value
|
||||
etree.cleanup_namespaces(root)
|
||||
return root
|
||||
48
unshackle/core/vault.py
Normal file
48
unshackle/core/vault.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class Vault(metaclass=ABCMeta):
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} {type(self).__name__}"
|
||||
|
||||
@abstractmethod
|
||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||
"""
|
||||
Get Key from Vault by KID (Key ID) and Service.
|
||||
|
||||
It does not get Key by PSSH as the PSSH can be different depending on it's implementation,
|
||||
or even how it was crafted. Some PSSH values may also actually be a CENC Header rather
|
||||
than a PSSH MP4 Box too, which makes the value even more confusingly different.
|
||||
|
||||
However, the KID never changes unless the video file itself has changed too, meaning the
|
||||
key for the presumed-matching KID wouldn't work, further proving matching by KID is
|
||||
superior.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||
"""Get All Keys from Vault by Service."""
|
||||
|
||||
@abstractmethod
|
||||
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
||||
"""Add KID:KEY to the Vault."""
|
||||
|
||||
@abstractmethod
|
||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
"""
|
||||
Add Multiple Content Keys with Key IDs for Service to the Vault.
|
||||
Pre-existing Content Keys are ignored/skipped.
|
||||
Raises PermissionError if the user has no permission to create the table.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_services(self) -> Iterator[str]:
|
||||
"""Get a list of Service Tags from Vault."""
|
||||
|
||||
|
||||
__all__ = ("Vault",)
|
||||
69
unshackle/core/vaults.py
Normal file
69
unshackle/core/vaults.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from typing import Any, Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.utilities import import_module_by_path
|
||||
from unshackle.core.vault import Vault
|
||||
|
||||
_VAULTS = sorted(
|
||||
(path for path in config.directories.vaults.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem
|
||||
)
|
||||
|
||||
_MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in _VAULTS}
|
||||
|
||||
|
||||
class Vaults:
|
||||
"""Keeps hold of Key Vaults with convenience functions, e.g. searching all vaults."""
|
||||
|
||||
def __init__(self, service: Optional[str] = None):
|
||||
self.service = service or ""
|
||||
self.vaults = []
|
||||
|
||||
def __iter__(self) -> Iterator[Vault]:
|
||||
return iter(self.vaults)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.vaults)
|
||||
|
||||
def load(self, type_: str, **kwargs: Any) -> None:
|
||||
"""Load a Vault into the vaults list."""
|
||||
module = _MODULES.get(type_)
|
||||
if not module:
|
||||
raise ValueError(f"Unable to find vault command by the name '{type_}'.")
|
||||
vault = module(**kwargs)
|
||||
self.vaults.append(vault)
|
||||
|
||||
def get_key(self, kid: Union[UUID, str]) -> tuple[Optional[str], Optional[Vault]]:
|
||||
"""Get Key from the first Vault it can by KID (Key ID) and Service."""
|
||||
for vault in self.vaults:
|
||||
key = vault.get_key(kid, self.service)
|
||||
if key and key.count("0") != len(key):
|
||||
return key, vault
|
||||
return None, None
|
||||
|
||||
def add_key(self, kid: Union[UUID, str], key: str, excluding: Optional[Vault] = None) -> int:
|
||||
"""Add a KID:KEY to all Vaults, optionally with an exclusion."""
|
||||
success = 0
|
||||
for vault in self.vaults:
|
||||
if vault != excluding:
|
||||
try:
|
||||
success += vault.add_key(self.service, kid, key)
|
||||
except (PermissionError, NotImplementedError):
|
||||
pass
|
||||
return success
|
||||
|
||||
def add_keys(self, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
"""
|
||||
Add multiple KID:KEYs to all Vaults. Duplicate Content Keys are skipped.
|
||||
PermissionErrors when the user cannot create Tables are absorbed and ignored.
|
||||
"""
|
||||
success = 0
|
||||
for vault in self.vaults:
|
||||
try:
|
||||
success += bool(vault.add_keys(self.service, kid_keys))
|
||||
except (PermissionError, NotImplementedError):
|
||||
pass
|
||||
return success
|
||||
|
||||
|
||||
__all__ = ("Vaults",)
|
||||
269
unshackle/services/EXAMPLE/__init__.py
Normal file
269
unshackle/services/EXAMPLE/__init__.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Optional, Union
|
||||
|
||||
import click
|
||||
from langcodes import Language
|
||||
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.credential import Credential
|
||||
from unshackle.core.manifests import DASH
|
||||
from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||
from unshackle.core.tracks import Chapter, Subtitle, Tracks
|
||||
|
||||
|
||||
class EXAMPLE(Service):
|
||||
"""
|
||||
Service code for domain.com
|
||||
Version: 1.0.0
|
||||
|
||||
Authorization: Cookies
|
||||
|
||||
Security: FHD@L3
|
||||
|
||||
Use full URL (for example - https://domain.com/details/20914) or title ID (for example - 20914).
|
||||
"""
|
||||
|
||||
TITLE_RE = r"^(?:https?://?domain\.com/details/)?(?P<title_id>[^/]+)"
|
||||
GEOFENCE = ("US", "UK")
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="EXAMPLE", short_help="https://domain.com")
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
||||
@click.option("-d", "--device", type=str, default="android_tv", help="Select device from the config file")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return EXAMPLE(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title, movie, device):
|
||||
super().__init__(ctx)
|
||||
|
||||
self.title = title
|
||||
self.movie = movie
|
||||
self.device = device
|
||||
|
||||
if self.config is None:
|
||||
raise Exception("Config is missing!")
|
||||
else:
|
||||
profile_name = ctx.parent.params.get("profile")
|
||||
if profile_name is None:
|
||||
profile_name = "default"
|
||||
self.profile = profile_name
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if not cookies:
|
||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
||||
|
||||
jwt_token = next((cookie.value for cookie in cookies if cookie.name == "streamco_token"), None)
|
||||
payload = json.loads(base64.urlsafe_b64decode(jwt_token.split(".")[1] + "==").decode("utf-8"))
|
||||
profile_id = payload.get("profileId", None)
|
||||
self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
|
||||
|
||||
cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
|
||||
|
||||
if cache:
|
||||
if cache.data["expires_in"] > int(datetime.now().timestamp()):
|
||||
self.log.info("Using cached tokens")
|
||||
else:
|
||||
self.log.info("Refreshing tokens")
|
||||
|
||||
refresh = self.session.post(
|
||||
url=self.config["endpoints"]["refresh"], data={"refresh_token": cache.data["refresh_data"]}
|
||||
).json()
|
||||
|
||||
cache.set(data=refresh)
|
||||
|
||||
else:
|
||||
self.log.info("Retrieving new tokens")
|
||||
|
||||
token = self.session.post(
|
||||
url=self.config["endpoints"]["login"],
|
||||
data={
|
||||
"token": jwt_token,
|
||||
"profileId": profile_id,
|
||||
},
|
||||
).json()
|
||||
|
||||
cache.set(data=token)
|
||||
|
||||
self.token = cache.data["token"]
|
||||
self.user_id = cache.data["userId"]
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
search = self.session.get(
|
||||
url=self.config["endpoints"]["search"], params={"q": self.title, "token": self.token}
|
||||
).json()
|
||||
|
||||
for result in search["entries"]:
|
||||
yield SearchResult(
|
||||
id_=result["id"],
|
||||
title=result["title"],
|
||||
label="SERIES" if result["programType"] == "series" else "MOVIE",
|
||||
url=result["url"],
|
||||
)
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
self.title = re.match(self.TITLE_RE, self.title).group(1)
|
||||
|
||||
metadata = self.session.get(
|
||||
url=self.config["endpoints"]["metadata"].format(title_id=self.title), params={"token": self.token}
|
||||
).json()
|
||||
|
||||
if metadata["programType"] == "movie":
|
||||
self.movie = True
|
||||
|
||||
if self.movie:
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=metadata["id"],
|
||||
service=self.__class__,
|
||||
name=metadata["title"],
|
||||
description=metadata["description"],
|
||||
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
||||
language=Language.find(metadata["languages"][0]),
|
||||
data=metadata,
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
episodes = []
|
||||
|
||||
for season in metadata["seasons"]:
|
||||
if "Trailers" not in season["title"]:
|
||||
season_data = self.session.get(url=season["url"], params={"token": self.token}).json()
|
||||
|
||||
for episode in season_data["entries"]:
|
||||
episodes.append(
|
||||
Episode(
|
||||
id_=episode["id"],
|
||||
service=self.__class__,
|
||||
title=metadata["title"],
|
||||
season=episode["season"],
|
||||
number=episode["episode"],
|
||||
name=episode["title"],
|
||||
description=episode["description"],
|
||||
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
||||
language=Language.find(metadata["languages"][0]),
|
||||
data=episode,
|
||||
)
|
||||
)
|
||||
return Series(episodes)
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
streams = self.session.post(
|
||||
url=self.config["endpoints"]["streams"],
|
||||
params={
|
||||
"token": self.token,
|
||||
"guid": title.id,
|
||||
},
|
||||
data={
|
||||
"type": self.config["client"][self.device]["type"],
|
||||
},
|
||||
).json()["media"]
|
||||
|
||||
self.license = {
|
||||
"url": streams["drm"]["url"],
|
||||
"data": streams["drm"]["data"],
|
||||
"session": streams["drm"]["session"],
|
||||
}
|
||||
|
||||
manifest_url = streams["url"].split("?")[0]
|
||||
|
||||
self.log.debug(f"Manifest URL: {manifest_url}")
|
||||
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
||||
|
||||
# Remove DRM-free ("clear") audio tracks
|
||||
tracks.audio = [
|
||||
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
|
||||
]
|
||||
|
||||
for track in tracks.audio:
|
||||
if track.channels == 6.0:
|
||||
track.channels = 5.1
|
||||
track_label = track.data["dash"]["adaptation_set"].get("label")
|
||||
if track_label and "Audio Description" in track_label:
|
||||
track.descriptive = True
|
||||
|
||||
tracks.subtitles.clear()
|
||||
if streams.get("captions"):
|
||||
for subtitle in streams["captions"]:
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=hashlib.md5(subtitle["url"].encode()).hexdigest()[0:6],
|
||||
url=subtitle["url"],
|
||||
codec=Subtitle.Codec.from_mime("vtt"),
|
||||
language=Language.get(subtitle["language"]),
|
||||
# cc=True if '(cc)' in subtitle['name'] else False,
|
||||
sdh=True,
|
||||
)
|
||||
)
|
||||
|
||||
if not self.movie:
|
||||
title.data["chapters"] = self.session.get(
|
||||
url=self.config["endpoints"]["metadata"].format(title_id=title.id), params={"token": self.token}
|
||||
).json()["chapters"]
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||
chapters = []
|
||||
|
||||
if title.data.get("chapters", []):
|
||||
for chapter in title.data["chapters"]:
|
||||
if chapter["name"] == "Intro":
|
||||
chapters.append(Chapter(timestamp=chapter["start"], name="Opening"))
|
||||
chapters.append(Chapter(timestamp=chapter["end"]))
|
||||
if chapter["name"] == "Credits":
|
||||
chapters.append(Chapter(timestamp=chapter["start"], name="Credits"))
|
||||
|
||||
return chapters
|
||||
|
||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||
"""Retrieve a PlayReady license for a given track."""
|
||||
|
||||
license_url = self.config["endpoints"].get("playready_license")
|
||||
if not license_url:
|
||||
raise ValueError("PlayReady license endpoint not configured")
|
||||
|
||||
response = self.session.post(
|
||||
url=license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||
license_url = self.license.get("url") or self.config["endpoints"].get("widevine_license")
|
||||
if not license_url:
|
||||
raise ValueError("Widevine license endpoint not configured")
|
||||
|
||||
response = self.session.post(
|
||||
url=license_url,
|
||||
data=challenge,
|
||||
params={
|
||||
"session": self.license.get("session"),
|
||||
"userId": self.user_id,
|
||||
},
|
||||
headers={
|
||||
"dt-custom-data": self.license.get("data"),
|
||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
return response.json().get("license")
|
||||
except ValueError:
|
||||
return response.content
|
||||
12
unshackle/services/EXAMPLE/config.yaml
Normal file
12
unshackle/services/EXAMPLE/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
endpoints:
|
||||
login: https://api.domain.com/v1/login
|
||||
metadata: https://api.domain.com/v1/metadata/{title_id}.json
|
||||
streams: https://api.domain.com/v1/streams
|
||||
playready_license: https://api.domain.com/v1/license/playready
|
||||
widevine_license: https://api.domain.com/v1/license/widevine
|
||||
|
||||
client:
|
||||
android_tv:
|
||||
user_agent: USER_AGENT
|
||||
license_user_agent: LICENSE_USER_AGENT
|
||||
type: DATA
|
||||
168
unshackle/unshackle.yaml
Normal file
168
unshackle/unshackle.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
# Group or Username to postfix to the end of all download filenames following a dash
|
||||
tag: user_tag
|
||||
|
||||
# Set terminal background color (custom option not in CONFIG.md)
|
||||
set_terminal_bg: false
|
||||
|
||||
# Muxing configuration
|
||||
muxing:
|
||||
set_title: false
|
||||
|
||||
# Login credentials for each Service
|
||||
credentials:
|
||||
EXAMPLE: email@example.com:password
|
||||
EXAMPLE2: username:password
|
||||
|
||||
# Override default directories used across unshackle
|
||||
directories:
|
||||
cache: Cache
|
||||
cookies: Cookies
|
||||
dcsl: DCSL # Device Certificate Status List
|
||||
downloads: Downloads
|
||||
logs: Logs
|
||||
temp: Temp
|
||||
wvds: WVDs
|
||||
prds: PRDs
|
||||
# Additional directories that can be configured:
|
||||
# commands: Commands
|
||||
# services: Services
|
||||
# vaults: Vaults
|
||||
# fonts: Fonts
|
||||
|
||||
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||
cdm:
|
||||
default: WVD_1
|
||||
EXAMPLE: PRD_1
|
||||
|
||||
# Use pywidevine Serve-compliant Remote CDMs
|
||||
remote_cdm:
|
||||
- name: "chrome"
|
||||
device_name: chrome
|
||||
device_type: CHROME
|
||||
system_id: 27175
|
||||
security_level: 3
|
||||
host: https://domain.com/api
|
||||
secret: secret_key
|
||||
- name: "chrome-2"
|
||||
device_name: chrome
|
||||
device_type: CHROME
|
||||
system_id: 26830
|
||||
security_level: 3
|
||||
host: https://domain-2.com/api
|
||||
secret: secret_key
|
||||
|
||||
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||
key_vaults:
|
||||
- type: SQLite
|
||||
name: Local
|
||||
path: key_store.db
|
||||
# Additional vault types:
|
||||
# - type: API
|
||||
# name: "Remote Vault"
|
||||
# uri: "https://key-vault.example.com"
|
||||
# token: "secret_token"
|
||||
# - type: MySQL
|
||||
# name: "MySQL Vault"
|
||||
# host: "127.0.0.1"
|
||||
# port: 3306
|
||||
# database: vault
|
||||
# username: user
|
||||
# password: pass
|
||||
|
||||
# Choose what software to use to download data
|
||||
downloader: aria2c
|
||||
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
|
||||
# Can also be a mapping:
|
||||
# downloader:
|
||||
# NF: requests
|
||||
# AMZN: n_m3u8dl_re
|
||||
# DSNP: n_m3u8dl_re
|
||||
# default: requests
|
||||
|
||||
# aria2c downloader configuration
|
||||
aria2c:
|
||||
max_concurrent_downloads: 4
|
||||
max_connection_per_server: 3
|
||||
split: 5
|
||||
file_allocation: falloc # none | prealloc | falloc | trunc
|
||||
|
||||
# N_m3u8DL-RE downloader configuration
|
||||
n_m3u8dl_re:
|
||||
thread_count: 16
|
||||
ad_keyword: "advertisement"
|
||||
use_proxy: true
|
||||
|
||||
# curl_impersonate downloader configuration
|
||||
curl_impersonate:
|
||||
browser: chrome120
|
||||
|
||||
# Pre-define default options and switches of the dl command
|
||||
dl:
|
||||
best: true
|
||||
sub_format: srt
|
||||
downloads: 4
|
||||
workers: 16
|
||||
lang:
|
||||
- en
|
||||
- fr
|
||||
EXAMPLE:
|
||||
bitrate: CBR
|
||||
|
||||
# Chapter Name to use when exporting a Chapter without a Name
|
||||
chapter_fallback_name: "Chapter {j:02}"
|
||||
|
||||
# Case-Insensitive dictionary of headers for all Services
|
||||
headers:
|
||||
Accept-Language: "en-US,en;q=0.8"
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36"
|
||||
|
||||
# Override default filenames used across unshackle
|
||||
filenames:
|
||||
log: "unshackle_{name}_{time}.log"
|
||||
config: "config.yaml"
|
||||
root_config: "unshackle.yaml"
|
||||
chapters: "Chapters_{title}_{random}.txt"
|
||||
subtitle: "Subtitle_{id}_{language}.srt"
|
||||
|
||||
# API key for The Movie Database (TMDB)
|
||||
tmdb_api_key: ""
|
||||
|
||||
# Configuration for pywidevine's serve functionality
|
||||
serve:
|
||||
users:
|
||||
secret_key_for_user:
|
||||
devices:
|
||||
- generic_nexus_4464_l3
|
||||
username: user
|
||||
# devices:
|
||||
# - '/path/to/device.wvd'
|
||||
|
||||
# Configuration data for each Service
|
||||
services:
|
||||
# Service-specific configuration goes here
|
||||
# EXAMPLE:
|
||||
# api_key: "service_specific_key"
|
||||
|
||||
# Legacy NordVPN configuration (use proxy_providers instead)
|
||||
nordvpn:
|
||||
username: ""
|
||||
password: ""
|
||||
servers:
|
||||
- us: 12
|
||||
|
||||
# External proxy provider services
|
||||
proxy_providers:
|
||||
nordvpn:
|
||||
username: username_from_service_credentials
|
||||
password: password_from_service_credentials
|
||||
servers:
|
||||
- us: 12 # force US server #12 for US proxies
|
||||
basic:
|
||||
GB:
|
||||
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)
|
||||
- "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow)
|
||||
AU:
|
||||
- "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney)
|
||||
- "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney)
|
||||
- "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane)
|
||||
BG: "https://username:password@bg-sof.prod.surfshark.com"
|
||||
118
unshackle/utils/base62.py
Normal file
118
unshackle/utils/base62.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
base62
|
||||
~~~~~~
|
||||
|
||||
Originated from http://blog.suminb.com/archives/558
|
||||
"""
|
||||
|
||||
__title__ = "base62"
|
||||
__author__ = "Sumin Byeon"
|
||||
__email__ = "suminb@gmail.com"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
BASE = 62
|
||||
CHARSET_DEFAULT = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
CHARSET_INVERTED = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def encode(n, charset=CHARSET_DEFAULT):
|
||||
"""Encodes a given integer ``n``."""
|
||||
|
||||
chs = []
|
||||
while n > 0:
|
||||
n, r = divmod(n, BASE)
|
||||
chs.insert(0, charset[r])
|
||||
|
||||
if not chs:
|
||||
return "0"
|
||||
|
||||
return "".join(chs)
|
||||
|
||||
|
||||
def encodebytes(barray, charset=CHARSET_DEFAULT):
|
||||
"""Encodes a bytestring into a base62 string.
|
||||
|
||||
:param barray: A byte array
|
||||
:type barray: bytes
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
_check_type(barray, bytes)
|
||||
|
||||
# Count the number of leading zeros.
|
||||
leading_zeros_count = 0
|
||||
for i in range(len(barray)):
|
||||
if barray[i] != 0:
|
||||
break
|
||||
leading_zeros_count += 1
|
||||
|
||||
# Encode the leading zeros as "0" followed by a character indicating the count.
|
||||
# This pattern may occur several times if there are many leading zeros.
|
||||
n, r = divmod(leading_zeros_count, len(charset) - 1)
|
||||
zero_padding = f"0{charset[-1]}" * n
|
||||
if r:
|
||||
zero_padding += f"0{charset[r]}"
|
||||
|
||||
# Special case: the input is empty, or is entirely null bytes.
|
||||
if leading_zeros_count == len(barray):
|
||||
return zero_padding
|
||||
|
||||
value = encode(int.from_bytes(barray, "big"), charset=charset)
|
||||
return zero_padding + value
|
||||
|
||||
|
||||
def decode(encoded, charset=CHARSET_DEFAULT):
|
||||
"""Decodes a base62 encoded value ``encoded``.
|
||||
|
||||
:type encoded: str
|
||||
:rtype: int
|
||||
"""
|
||||
_check_type(encoded, str)
|
||||
|
||||
length, i, v = len(encoded), 0, 0
|
||||
for x in encoded:
|
||||
v += _value(x, charset=charset) * (BASE ** (length - (i + 1)))
|
||||
i += 1
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def decodebytes(encoded, charset=CHARSET_DEFAULT):
|
||||
"""Decodes a string of base62 data into a bytes object.
|
||||
|
||||
:param encoded: A string to be decoded in base62
|
||||
:type encoded: str
|
||||
:rtype: bytes
|
||||
"""
|
||||
|
||||
leading_null_bytes = b""
|
||||
while encoded.startswith("0") and len(encoded) >= 2:
|
||||
leading_null_bytes += b"\x00" * _value(encoded[1], charset)
|
||||
encoded = encoded[2:]
|
||||
|
||||
decoded = decode(encoded, charset=charset)
|
||||
buf = bytearray()
|
||||
while decoded > 0:
|
||||
buf.append(decoded & 0xFF)
|
||||
decoded //= 256
|
||||
buf.reverse()
|
||||
|
||||
return leading_null_bytes + bytes(buf)
|
||||
|
||||
|
||||
def _value(ch, charset):
|
||||
"""Decodes an individual digit of a base62 encoded string."""
|
||||
|
||||
try:
|
||||
return charset.index(ch)
|
||||
except ValueError:
|
||||
raise ValueError("base62: Invalid character (%s)" % ch)
|
||||
|
||||
|
||||
def _check_type(value, expected_type):
|
||||
"""Checks if the input is in an appropriate type."""
|
||||
|
||||
if not isinstance(value, expected_type):
|
||||
msg = "Expected {} object, not {}".format(expected_type, value.__class__.__name__)
|
||||
raise TypeError(msg)
|
||||
24
unshackle/utils/osenvironment.py
Normal file
24
unshackle/utils/osenvironment.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import platform
|
||||
|
||||
|
||||
def get_os_arch(name: str) -> str:
|
||||
"""Builds a name-os-arch based on the input name, system, architecture."""
|
||||
os_name = platform.system().lower()
|
||||
os_arch = platform.machine().lower()
|
||||
|
||||
# Map platform.system() output to desired OS name
|
||||
if os_name == "windows":
|
||||
os_name = "win"
|
||||
elif os_name == "darwin":
|
||||
os_name = "osx"
|
||||
else:
|
||||
os_name = "linux"
|
||||
|
||||
# Map platform.machine() output to desired architecture
|
||||
if os_arch in ["x86_64", "amd64"]:
|
||||
os_arch = "x64"
|
||||
elif os_arch == "arm64":
|
||||
os_arch = "arm64"
|
||||
|
||||
# Construct the dependency name in the desired format using the input name
|
||||
return f"{name}-{os_name}-{os_arch}"
|
||||
184
unshackle/vaults/API.py
Normal file
184
unshackle/vaults/API.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from typing import Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core import __version__
|
||||
from unshackle.core.vault import Vault
|
||||
|
||||
|
||||
class API(Vault):
|
||||
"""Key Vault using a simple RESTful HTTP API call."""
|
||||
|
||||
def __init__(self, name: str, uri: str, token: str):
|
||||
super().__init__(name)
|
||||
self.uri = uri.rstrip("/")
|
||||
self.session = Session()
|
||||
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
data = self.session.get(
|
||||
url=f"{self.uri}/{service.lower()}/{kid}", headers={"Accept": "application/json"}
|
||||
).json()
|
||||
|
||||
code = int(data.get("code", 0))
|
||||
message = data.get("message")
|
||||
error = {
|
||||
0: None,
|
||||
1: Exceptions.AuthRejected,
|
||||
2: Exceptions.TooManyRequests,
|
||||
3: Exceptions.ServiceTagInvalid,
|
||||
4: Exceptions.KeyIdInvalid,
|
||||
}.get(code, ValueError)
|
||||
|
||||
if error:
|
||||
raise error(f"{message} ({code})")
|
||||
|
||||
content_key = data.get("content_key")
|
||||
if not content_key:
|
||||
return None
|
||||
|
||||
if not isinstance(content_key, str):
|
||||
raise ValueError(f"Expected {content_key} to be {str}, was {type(content_key)}")
|
||||
|
||||
return content_key
|
||||
|
||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
data = self.session.get(
|
||||
url=f"{self.uri}/{service.lower()}",
|
||||
params={"page": page, "total": 10},
|
||||
headers={"Accept": "application/json"},
|
||||
).json()
|
||||
|
||||
code = int(data.get("code", 0))
|
||||
message = data.get("message")
|
||||
error = {
|
||||
0: None,
|
||||
1: Exceptions.AuthRejected,
|
||||
2: Exceptions.TooManyRequests,
|
||||
3: Exceptions.PageInvalid,
|
||||
4: Exceptions.ServiceTagInvalid,
|
||||
}.get(code, ValueError)
|
||||
|
||||
if error:
|
||||
raise error(f"{message} ({code})")
|
||||
|
||||
content_keys = data.get("content_keys")
|
||||
if content_keys:
|
||||
if not isinstance(content_keys, dict):
|
||||
raise ValueError(f"Expected {content_keys} to be {dict}, was {type(content_keys)}")
|
||||
|
||||
for key_id, key in content_keys.items():
|
||||
yield key_id, key
|
||||
|
||||
pages = int(data["pages"])
|
||||
if pages <= page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
data = self.session.post(
|
||||
url=f"{self.uri}/{service.lower()}/{kid}", json={"content_key": key}, headers={"Accept": "application/json"}
|
||||
).json()
|
||||
|
||||
code = int(data.get("code", 0))
|
||||
message = data.get("message")
|
||||
error = {
|
||||
0: None,
|
||||
1: Exceptions.AuthRejected,
|
||||
2: Exceptions.TooManyRequests,
|
||||
3: Exceptions.ServiceTagInvalid,
|
||||
4: Exceptions.KeyIdInvalid,
|
||||
5: Exceptions.ContentKeyInvalid,
|
||||
}.get(code, ValueError)
|
||||
|
||||
if error:
|
||||
raise error(f"{message} ({code})")
|
||||
|
||||
# the kid:key was new to the vault (optional)
|
||||
added = bool(data.get("added"))
|
||||
# the key for kid was changed/updated (optional)
|
||||
updated = bool(data.get("updated"))
|
||||
|
||||
return added or updated
|
||||
|
||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
data = self.session.post(
|
||||
url=f"{self.uri}/{service.lower()}",
|
||||
json={"content_keys": {str(kid).replace("-", ""): key for kid, key in kid_keys.items()}},
|
||||
headers={"Accept": "application/json"},
|
||||
).json()
|
||||
|
||||
code = int(data.get("code", 0))
|
||||
message = data.get("message")
|
||||
error = {
|
||||
0: None,
|
||||
1: Exceptions.AuthRejected,
|
||||
2: Exceptions.TooManyRequests,
|
||||
3: Exceptions.ServiceTagInvalid,
|
||||
4: Exceptions.KeyIdInvalid,
|
||||
5: Exceptions.ContentKeyInvalid,
|
||||
}.get(code, ValueError)
|
||||
|
||||
if error:
|
||||
raise error(f"{message} ({code})")
|
||||
|
||||
# each kid:key that was new to the vault (optional)
|
||||
added = int(data.get("added"))
|
||||
# each key for a kid that was changed/updated (optional)
|
||||
updated = int(data.get("updated"))
|
||||
|
||||
return added + updated
|
||||
|
||||
def get_services(self) -> Iterator[str]:
|
||||
data = self.session.post(url=self.uri, headers={"Accept": "application/json"}).json()
|
||||
|
||||
code = int(data.get("code", 0))
|
||||
message = data.get("message")
|
||||
error = {
|
||||
0: None,
|
||||
1: Exceptions.AuthRejected,
|
||||
2: Exceptions.TooManyRequests,
|
||||
}.get(code, ValueError)
|
||||
|
||||
if error:
|
||||
raise error(f"{message} ({code})")
|
||||
|
||||
service_list = data.get("service_list", [])
|
||||
|
||||
if not isinstance(service_list, list):
|
||||
raise ValueError(f"Expected {service_list} to be {list}, was {type(service_list)}")
|
||||
|
||||
for service in service_list:
|
||||
yield service
|
||||
|
||||
|
||||
class Exceptions:
|
||||
class AuthRejected(Exception):
|
||||
"""Authentication Error Occurred, is your token valid? Do you have permission to make this call?"""
|
||||
|
||||
class TooManyRequests(Exception):
|
||||
"""Rate Limited; Sent too many requests in a given amount of time."""
|
||||
|
||||
class PageInvalid(Exception):
|
||||
"""Requested page does not exist."""
|
||||
|
||||
class ServiceTagInvalid(Exception):
|
||||
"""The Service Tag is invalid."""
|
||||
|
||||
class KeyIdInvalid(Exception):
|
||||
"""The Key ID is invalid."""
|
||||
|
||||
class ContentKeyInvalid(Exception):
|
||||
"""The Content Key is invalid."""
|
||||
326
unshackle/vaults/HTTP.py
Normal file
326
unshackle/vaults/HTTP.py
Normal file
@@ -0,0 +1,326 @@
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core import __version__
|
||||
from unshackle.core.vault import Vault
|
||||
|
||||
|
||||
class InsertResult(Enum):
|
||||
FAILURE = 0
|
||||
SUCCESS = 1
|
||||
ALREADY_EXISTS = 2
|
||||
|
||||
|
||||
class HTTP(Vault):
|
||||
"""Key Vault using HTTP API with support for both query parameters and JSON payloads."""
|
||||
|
||||
def __init__(self, name: str, host: str, password: str, username: Optional[str] = None, api_mode: str = "query"):
|
||||
"""
|
||||
Initialize HTTP Vault.
|
||||
|
||||
Args:
|
||||
name: Vault name
|
||||
host: Host URL
|
||||
password: Password for query mode or API token for json mode
|
||||
username: Username (required for query mode, ignored for json mode)
|
||||
api_mode: "query" for query parameters or "json" for JSON API
|
||||
"""
|
||||
super().__init__(name)
|
||||
self.url = host.rstrip("/")
|
||||
self.password = password
|
||||
self.username = username
|
||||
self.api_mode = api_mode.lower()
|
||||
self.current_title = None
|
||||
self.session = Session()
|
||||
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||
self.api_session_id = None
|
||||
|
||||
# Validate configuration based on mode
|
||||
if self.api_mode == "query" and not self.username:
|
||||
raise ValueError("Username is required for query mode")
|
||||
elif self.api_mode not in ["query", "json"]:
|
||||
raise ValueError("api_mode must be either 'query' or 'json'")
|
||||
|
||||
def request(self, method: str, params: dict = None) -> dict:
|
||||
"""Make a request to the JSON API vault."""
|
||||
if self.api_mode != "json":
|
||||
raise ValueError("request method is only available in json mode")
|
||||
|
||||
request_payload = {
|
||||
"method": method,
|
||||
"params": {
|
||||
**(params or {}),
|
||||
"session_id": self.api_session_id,
|
||||
},
|
||||
"token": self.password,
|
||||
}
|
||||
|
||||
r = self.session.post(self.url, json=request_payload)
|
||||
|
||||
if r.status_code == 404:
|
||||
return {"status": "not_found"}
|
||||
|
||||
if not r.ok:
|
||||
raise ValueError(f"API returned HTTP Error {r.status_code}: {r.reason.title()}")
|
||||
|
||||
try:
|
||||
res = r.json()
|
||||
except json.JSONDecodeError:
|
||||
if r.status_code == 404:
|
||||
return {"status": "not_found"}
|
||||
raise ValueError(f"API returned an invalid response: {r.text}")
|
||||
|
||||
if res.get("status_code") != 200:
|
||||
raise ValueError(f"API returned an error: {res['status_code']} - {res['message']}")
|
||||
|
||||
if session_id := res.get("message", {}).get("session_id"):
|
||||
self.api_session_id = session_id
|
||||
|
||||
return res.get("message", res)
|
||||
|
||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
if self.api_mode == "json":
|
||||
try:
|
||||
title = getattr(self, "current_title", None)
|
||||
response = self.request(
|
||||
"GetKey",
|
||||
{
|
||||
"kid": kid,
|
||||
"service": service.lower(),
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
if response.get("status") == "not_found":
|
||||
return None
|
||||
keys = response.get("keys", [])
|
||||
for key_entry in keys:
|
||||
if key_entry["kid"] == kid:
|
||||
return key_entry["key"]
|
||||
except Exception as e:
|
||||
print(f"Failed to get key ({e.__class__.__name__}: {e})")
|
||||
return None
|
||||
return None
|
||||
else: # query mode
|
||||
response = self.session.get(
|
||||
self.url,
|
||||
params={"service": service.lower(), "username": self.username, "password": self.password, "kid": kid},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get("status_code") != 200 or not data.get("keys"):
|
||||
return None
|
||||
|
||||
return data["keys"][0]["key"]
|
||||
|
||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||
if self.api_mode == "json":
|
||||
# JSON API doesn't support getting all keys, so return empty iterator
|
||||
# This will cause the copy command to rely on the API's internal duplicate handling
|
||||
return iter([])
|
||||
else: # query mode
|
||||
response = self.session.get(
|
||||
self.url, params={"service": service.lower(), "username": self.username, "password": self.password}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get("status_code") != 200 or not data.get("keys"):
|
||||
return
|
||||
|
||||
for key_entry in data["keys"]:
|
||||
yield key_entry["kid"], key_entry["key"]
|
||||
|
||||
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
title = getattr(self, "current_title", None)
|
||||
|
||||
if self.api_mode == "json":
|
||||
try:
|
||||
response = self.request(
|
||||
"InsertKey",
|
||||
{
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"service": service.lower(),
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
if response.get("status") == "not_found":
|
||||
return False
|
||||
return response.get("inserted", False)
|
||||
except Exception:
|
||||
return False
|
||||
else: # query mode
|
||||
response = self.session.get(
|
||||
self.url,
|
||||
params={
|
||||
"service": service.lower(),
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
return data.get("status_code") == 200
|
||||
|
||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
for kid, key in kid_keys.items():
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
processed_kid_keys = {
|
||||
str(kid).replace("-", "") if isinstance(kid, UUID) else kid: key for kid, key in kid_keys.items()
|
||||
}
|
||||
|
||||
inserted_count = 0
|
||||
title = getattr(self, "current_title", None)
|
||||
|
||||
if self.api_mode == "json":
|
||||
for kid, key in processed_kid_keys.items():
|
||||
try:
|
||||
response = self.request(
|
||||
"InsertKey",
|
||||
{
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"service": service.lower(),
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
if response.get("status") == "not_found":
|
||||
continue
|
||||
if response.get("inserted", False):
|
||||
inserted_count += 1
|
||||
except Exception:
|
||||
continue
|
||||
else: # query mode
|
||||
for kid, key in processed_kid_keys.items():
|
||||
response = self.session.get(
|
||||
self.url,
|
||||
params={
|
||||
"service": service.lower(),
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get("status_code") == 200 and data.get("inserted", True):
|
||||
inserted_count += 1
|
||||
|
||||
return inserted_count
|
||||
|
||||
def get_services(self) -> Iterator[str]:
|
||||
if self.api_mode == "json":
|
||||
try:
|
||||
response = self.request("GetServices")
|
||||
services = response.get("services", [])
|
||||
for service in services:
|
||||
yield service
|
||||
except Exception:
|
||||
return iter([])
|
||||
else: # query mode
|
||||
response = self.session.get(
|
||||
self.url, params={"username": self.username, "password": self.password, "list_services": True}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get("status_code") != 200:
|
||||
return
|
||||
|
||||
services = data.get("services", [])
|
||||
for service in services:
|
||||
yield service
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set a title to be used for the next key insertions.
|
||||
This is optional and will be sent with add_key requests if available.
|
||||
"""
|
||||
self.current_title = title
|
||||
|
||||
def insert_key_with_result(
|
||||
self, service: str, kid: Union[UUID, str], key: str, title: Optional[str] = None
|
||||
) -> InsertResult:
|
||||
"""
|
||||
Insert a key and return detailed result information.
|
||||
This method provides more granular feedback than the standard add_key method.
|
||||
Available in both API modes.
|
||||
"""
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
if title is None:
|
||||
title = getattr(self, "current_title", None)
|
||||
|
||||
if self.api_mode == "json":
|
||||
try:
|
||||
response = self.request(
|
||||
"InsertKey",
|
||||
{
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"service": service.lower(),
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
if response.get("status") == "not_found":
|
||||
return InsertResult.FAILURE
|
||||
|
||||
if response.get("inserted", False):
|
||||
return InsertResult.SUCCESS
|
||||
else:
|
||||
return InsertResult.ALREADY_EXISTS
|
||||
|
||||
except Exception:
|
||||
return InsertResult.FAILURE
|
||||
else: # query mode
|
||||
response = self.session.get(
|
||||
self.url,
|
||||
params={
|
||||
"service": service.lower(),
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"kid": kid,
|
||||
"key": key,
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
if data.get("status_code") == 200:
|
||||
if data.get("inserted", True):
|
||||
return InsertResult.SUCCESS
|
||||
else:
|
||||
return InsertResult.ALREADY_EXISTS
|
||||
else:
|
||||
return InsertResult.FAILURE
|
||||
except Exception:
|
||||
return InsertResult.FAILURE
|
||||
244
unshackle/vaults/MySQL.py
Normal file
244
unshackle/vaults/MySQL.py
Normal file
@@ -0,0 +1,244 @@
|
||||
import threading
|
||||
from typing import Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.vault import Vault
|
||||
|
||||
|
||||
class MySQL(Vault):
|
||||
"""Key Vault using a remotely-accessed mysql database connection."""
|
||||
|
||||
def __init__(self, name: str, host: str, database: str, username: str, **kwargs):
|
||||
"""
|
||||
All extra arguments provided via **kwargs will be sent to pymysql.connect.
|
||||
This can be used to provide more specific connection information.
|
||||
"""
|
||||
super().__init__(name)
|
||||
self.slug = f"{host}:{database}:{username}"
|
||||
self.conn_factory = ConnectionFactory(
|
||||
dict(host=host, db=database, user=username, cursorclass=DictCursor, **kwargs)
|
||||
)
|
||||
|
||||
self.permissions = self.get_permissions()
|
||||
if not self.has_permission("SELECT"):
|
||||
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
|
||||
|
||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||
if not self.has_table(service):
|
||||
# no table, no key, simple
|
||||
return None
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=%s AND `key_`!=%s",
|
||||
(kid, "0" * 32),
|
||||
)
|
||||
cek = cursor.fetchone()
|
||||
if not cek:
|
||||
return None
|
||||
return cek["key_"]
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||
if not self.has_table(service):
|
||||
# no table, no keys, simple
|
||||
return None
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"SELECT `kid`, `key_` FROM `{service}` WHERE `key_`!=%s",
|
||||
("0" * 32,),
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
yield row["kid"], row["key_"]
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if not self.has_permission("INSERT", table=service):
|
||||
raise PermissionError(f"MySQL vault {self.slug} has no INSERT permission.")
|
||||
|
||||
if not self.has_table(service):
|
||||
try:
|
||||
self.create_table(service)
|
||||
except PermissionError:
|
||||
return False
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"SELECT `id` FROM `{service}` WHERE `kid`=%s AND `key_`=%s",
|
||||
(kid, key),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# table already has this exact KID:KEY stored
|
||||
return True
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"INSERT INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
||||
(kid, key),
|
||||
)
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return True
|
||||
|
||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
for kid, key in kid_keys.items():
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if not self.has_permission("INSERT", table=service):
|
||||
raise PermissionError(f"MySQL vault {self.slug} has no INSERT permission.")
|
||||
|
||||
if not self.has_table(service):
|
||||
try:
|
||||
self.create_table(service)
|
||||
except PermissionError:
|
||||
return 0
|
||||
|
||||
if not isinstance(kid_keys, dict):
|
||||
raise ValueError(f"The kid_keys provided is not a dictionary, {kid_keys!r}")
|
||||
if not all(isinstance(kid, (str, UUID)) and isinstance(key_, str) for kid, key_ in kid_keys.items()):
|
||||
raise ValueError("Expecting dict with Key of str/UUID and value of str.")
|
||||
|
||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.executemany(
|
||||
# TODO: SQL injection risk
|
||||
f"INSERT IGNORE INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
||||
kid_keys.items(),
|
||||
)
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
def get_services(self) -> Iterator[str]:
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SHOW TABLES")
|
||||
for table in cursor.fetchall():
|
||||
# each entry has a key named `Tables_in_<db name>`
|
||||
yield Services.get_tag(list(table.values())[0])
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def has_table(self, name: str) -> bool:
|
||||
"""Check if the Vault has a Table with the specified name."""
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT count(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s",
|
||||
(conn.db, name),
|
||||
)
|
||||
return list(cursor.fetchone().values())[0] == 1
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def create_table(self, name: str):
|
||||
"""Create a Table with the specified name if not yet created."""
|
||||
if self.has_table(name):
|
||||
return
|
||||
|
||||
if not self.has_permission("CREATE"):
|
||||
raise PermissionError(f"MySQL vault {self.slug} has no CREATE permission.")
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {name} (
|
||||
id int AUTO_INCREMENT PRIMARY KEY,
|
||||
kid VARCHAR(64) NOT NULL,
|
||||
key_ VARCHAR(64) NOT NULL,
|
||||
UNIQUE(kid, key_)
|
||||
);
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
def get_permissions(self) -> list:
|
||||
"""Get and parse Grants to a more easily usable list tuple array."""
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SHOW GRANTS")
|
||||
grants = cursor.fetchall()
|
||||
grants = [next(iter(x.values())) for x in grants]
|
||||
grants = [tuple(x[6:].split(" TO ")[0].split(" ON ")) for x in list(grants)]
|
||||
grants = [
|
||||
(
|
||||
list(map(str.strip, perms.replace("ALL PRIVILEGES", "*").split(","))),
|
||||
location.replace("`", "").split("."),
|
||||
)
|
||||
for perms, location in grants
|
||||
]
|
||||
return grants
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
def has_permission(self, operation: str, database: Optional[str] = None, table: Optional[str] = None) -> bool:
|
||||
"""Check if the current connection has a specific permission."""
|
||||
grants = [x for x in self.permissions if x[0] == ["*"] or operation.upper() in x[0]]
|
||||
if grants and database:
|
||||
grants = [x for x in grants if x[1][0] in (database, "*")]
|
||||
if grants and table:
|
||||
grants = [x for x in grants if x[1][1] in (table, "*")]
|
||||
return bool(grants)
|
||||
|
||||
|
||||
class ConnectionFactory:
|
||||
def __init__(self, con: dict):
|
||||
self._con = con
|
||||
self._store = threading.local()
|
||||
|
||||
def _create_connection(self) -> pymysql.Connection:
|
||||
return pymysql.connect(**self._con)
|
||||
|
||||
def get(self) -> pymysql.Connection:
|
||||
if not hasattr(self._store, "conn"):
|
||||
self._store.conn = self._create_connection()
|
||||
return self._store.conn
|
||||
179
unshackle/vaults/SQLite.py
Normal file
179
unshackle/vaults/SQLite.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
from typing import Iterator, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.vault import Vault
|
||||
|
||||
|
||||
class SQLite(Vault):
|
||||
"""Key Vault using a locally-accessed sqlite DB file."""
|
||||
|
||||
def __init__(self, name: str, path: Union[str, Path]):
|
||||
super().__init__(name)
|
||||
self.path = Path(path).expanduser()
|
||||
# TODO: Use a DictCursor or such to get fetches as dict?
|
||||
self.conn_factory = ConnectionFactory(self.path)
|
||||
|
||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||
if not self.has_table(service):
|
||||
# no table, no key, simple
|
||||
return None
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32))
|
||||
cek = cursor.fetchone()
|
||||
if not cek:
|
||||
return None
|
||||
return cek[1]
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||
if not self.has_table(service):
|
||||
# no table, no keys, simple
|
||||
return None
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(f"SELECT `kid`, `key_` FROM `{service}` WHERE `key_`!=?", ("0" * 32,))
|
||||
for kid, key_ in cursor.fetchall():
|
||||
yield kid, key_
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if not self.has_table(service):
|
||||
self.create_table(service)
|
||||
|
||||
if isinstance(kid, UUID):
|
||||
kid = kid.hex
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"SELECT `id` FROM `{service}` WHERE `kid`=? AND `key_`=?",
|
||||
(kid, key),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# table already has this exact KID:KEY stored
|
||||
return True
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"INSERT INTO `{service}` (kid, key_) VALUES (?, ?)",
|
||||
(kid, key),
|
||||
)
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return True
|
||||
|
||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||
for kid, key in kid_keys.items():
|
||||
if not key or key.count("0") == len(key):
|
||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||
|
||||
if not self.has_table(service):
|
||||
self.create_table(service)
|
||||
|
||||
if not isinstance(kid_keys, dict):
|
||||
raise ValueError(f"The kid_keys provided is not a dictionary, {kid_keys!r}")
|
||||
if not all(isinstance(kid, (str, UUID)) and isinstance(key_, str) for kid, key_ in kid_keys.items()):
|
||||
raise ValueError("Expecting dict with Key of str/UUID and value of str.")
|
||||
|
||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.executemany(
|
||||
# TODO: SQL injection risk
|
||||
f"INSERT OR IGNORE INTO `{service}` (kid, key_) VALUES (?, ?)",
|
||||
kid_keys.items(),
|
||||
)
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
def get_services(self) -> Iterator[str]:
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
for (name,) in cursor.fetchall():
|
||||
if name != "sqlite_sequence":
|
||||
yield Services.get_tag(name)
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def has_table(self, name: str) -> bool:
|
||||
"""Check if the Vault has a Table with the specified name."""
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name=?", (name,))
|
||||
return cursor.fetchone()[0] == 1
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def create_table(self, name: str):
|
||||
"""Create a Table with the specified name if not yet created."""
|
||||
if self.has_table(name):
|
||||
return
|
||||
|
||||
conn = self.conn_factory.get()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
# TODO: SQL injection risk
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {name} (
|
||||
"id" INTEGER NOT NULL UNIQUE,
|
||||
"kid" TEXT NOT NULL COLLATE NOCASE,
|
||||
"key_" TEXT NOT NULL COLLATE NOCASE,
|
||||
PRIMARY KEY("id" AUTOINCREMENT),
|
||||
UNIQUE("kid", "key_")
|
||||
);
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
|
||||
class ConnectionFactory:
|
||||
def __init__(self, path: Union[str, Path]):
|
||||
self._path = path
|
||||
self._store = threading.local()
|
||||
|
||||
def _create_connection(self) -> Connection:
|
||||
return sqlite3.connect(self._path)
|
||||
|
||||
def get(self) -> Connection:
|
||||
if not hasattr(self._store, "conn"):
|
||||
self._store.conn = self._create_connection()
|
||||
return self._store.conn
|
||||
0
unshackle/vaults/__init__.py
Normal file
0
unshackle/vaults/__init__.py
Normal file
Reference in New Issue
Block a user