Release v3.2.0

This commit is contained in:
hyugogirubato
2024-03-17 14:47:33 +01:00
parent 2edbd2f0c7
commit 4fbb7fd73a
367 changed files with 2922 additions and 415 deletions
-73
View File
@@ -1,73 +0,0 @@
# PyCBZHelper
[![License](https://img.shields.io/github/license/hyugogirubato/pycbzhelper)](https://github.com/hyugogirubato/pycbzhelper/blob/main/LICENSE)
[![Release](https://img.shields.io/github/release-date/hyugogirubato/pycbzhelper)](https://github.com/hyugogirubato/pycbzhelper/releases)
[![Latest Version](https://img.shields.io/pypi/v/pycbzhelper)](https://pypi.org/project/pycbzhelper/)
PyCBZHelper is a Python library for creating CBZ (Comic Book Zip) files with metadata. It provides functionality to
generate a CBZ file from a list of image pages and associated comic book metadata.
## Features
- Create CBZ files from images.
- Generate ComicInfo.xml metadata.
- Support for various metadata fields.
- Handle page files from local disk or web URLs.
- Automatic cleanup of temporary files.
## Installation
You can install PyCBZHelper using pip:
````shell
pip install pycbzhelper
````
## Usage
Here's a basic example of how to use PyCBZHelper:
````python
from pycbzhelper import Helper
from pathlib import Path
PARENT = Path(__name__).resolve().parent
if __name__ == "__main__":
# Define metadata for the comic
metadata = {
"Title": "My Comic",
"Series": "Comic Series",
"Number": "1",
"Pages": [
{"File": PARENT / "image1.jpg"},
{"File": PARENT / "image2.jpg"},
]
# Add more metadata fields here
}
# Define the path to the output CBZ file
output_path = PARENT / "output.cbz"
# Create an instance of the Helper class
helper = Helper(metadata)
# Create the CBZ file
helper.create_cbz(output_path)
````
For more information on how to use PyCBZHelper, please refer to
the [documentation](https://github.com/hyugogirubato/pycbzhelper/blob/main/docs/schema).
### Exceptions
The following exceptions can be raised by PyCBZHelper:
- `InvalidKeyValue`: Raised when a key value is invalid.
- `MissingPageFile`: Raised when no page files are available.
- `InvalidFileExtension`: Raised when an invalid file extension is used.
- `FileNotFound`: Raised when the specified source file is not found.
### License
This project is licensed under the [GPL v3 License](https://github.com/hyugogirubato/pycbzhelper/blob/main/LICENSE).
+176
View File
@@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# 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
# 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
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# 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
.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/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
+16 -4
View File
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.0] - 2024-03-17
### Changed
- New code structure.
- Deleting temporary files usage.
- Automatic correction of variable types.
- Simplifying constants.
- Binary input support for pages.
- Project `pycbzhelper` renamed to `cbz`.
## [3.1.2] - 2023-08-13 ## [3.1.2] - 2023-08-13
### Fixed ### Fixed
@@ -42,7 +53,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Initial release. - Initial release.
[3.1.2]: https://github.com/hyugogirubato/pycbzhelper/releases/tag/v3.1.2 [3.2.0]: https://github.com/hyugogirubato/cbz/releases/tag/v3.2.0
[3.1.1]: https://github.com/hyugogirubato/pycbzhelper/releases/tag/v3.1.1 [3.1.2]: https://github.com/hyugogirubato/cbz/releases/tag/v3.1.2
[3.1.0]: https://github.com/hyugogirubato/pycbzhelper/releases/tag/v3.1.0 [3.1.1]: https://github.com/hyugogirubato/cbz/releases/tag/v3.1.1
[3.0.1]: https://github.com/hyugogirubato/pycbzhelper/releases/tag/v3.0.1 [3.1.0]: https://github.com/hyugogirubato/cbz/releases/tag/v3.1.0
[3.0.1]: https://github.com/hyugogirubato/cbz/releases/tag/v3.0.1
View File
+74
View File
@@ -0,0 +1,74 @@
# CBZ
[![License](https://img.shields.io/github/license/hyugogirubato/cbz)](https://github.com/hyugogirubato/cbz/blob/main/LICENSE)
[![Release](https://img.shields.io/github/release-date/hyugogirubato/cbz)](https://github.com/hyugogirubato/cbz/releases)
[![Latest Version](https://img.shields.io/pypi/v/cbz)](https://pypi.org/project/cbz/)
CBZ is a Python package designed to facilitate the creation, manipulation, and extraction of comic book archive files in the CBZ format. It allows users to programmatically generate CBZ files from a collection of images, add metadata to the archives, and unpack existing CBZ files. This library is ideal for comic book enthusiasts, archivists, and developers working on applications involving digital comic book distributions.
## Features
- **Create CBZ Files**: Pack a series of images into a CBZ file with ease.
- **Extract CBZ Files**: Unpack images and metadata from existing CBZ files.
- **Manage Metadata**: Add, update, and retrieve metadata from CBZ files, including titles, authors, volume numbers, and more.
- **Support for ComicInfo.xml**: Utilizes the ComicInfo.xml standard for embedding comic book metadata within CBZ files.
- **Flexible Image Handling**: Load images from various formats and ensure they are correctly formatted and ordered within the CBZ file.
- **High-Level API**: Offers a simple, high-level API for common tasks, while also providing access to lower-level functions for advanced usage.
## Installation
To install the CBZ Library, you can use pip:
```bash
pip install cbz
```
## Quick Start
Here's a quick example of how to create a CBZ file from a series of images:
```python
from pathlib import Path
from cbz.page import PageInfo
from cbz.comic import ComicInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
if __name__ == '__main__':
# Define the path to your images
images_path = Path('path/to/your/images')
# Load images and create page objects
pages = [PageInfo.load(path) for path in images_path.iterdir()]
# Create a ComicInfo object with your comic's metadata
comic = ComicInfo.from_pages(
pages=pages,
title='Your Comic Title',
series='Your Comic Series',
number='1',
language_iso='en',
format=Format.WEB_COMIC,
black_white=YesNo.NO,
manga=Manga.NO,
age_rating=AgeRating.PENDING
)
# Pack the comic into a CBZ file
cbz_content = comic.pack()
# Save the CBZ file
cbz_path = Path('your_comic.cbz')
cbz_path.write_bytes(cbz_content)
```
## Documentation
For detailed documentation, including the full API reference and more examples, please refer to the [official CBZ Library documentation](https://en.wikipedia.org/wiki/Comic_book_archive).
## License
The CBZ Library is released under the MIT License. See [LICENSE](LICENSE) for details.
## Acknowledgments
This library was developed with the needs of comic book fans and digital archivists in mind. We hope it helps you in managing and enjoying your comic book collections.
+5
View File
@@ -0,0 +1,5 @@
from .comic import ComicInfo
from .constants import *
from .page import PageInfo
__version__ = '3.2.0'
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import io
import json
import zipfile
from typing import Union
import xmltodict
from langcodes import Language
from pathlib import Path
from cbz import utils
from cbz.constants import YesNo, Manga, AgeRating, Format
from cbz.page import PageInfo
def rating(value: int) -> int:
assert -1 <= value <= 5, 'Rating must be between 0 and 5'
return value
def language(value: str) -> str:
if value and not Language.get(value).is_valid():
raise ValueError('Invalid language')
return value
class ComicInfo:
def __init__(self, pages: [PageInfo], **kwargs):
self.__pages = pages
self.__info = {
'Title': str(kwargs.get('title', '')),
'Series': str(kwargs.get('series', '')),
'Number': str(kwargs.get('number', '')),
'Count': int(kwargs.get('count', -1)),
'Volume': int(kwargs.get('volume', -1)),
'AlternateSeries': str(kwargs.get('alternate_series', '')),
'AlternateNumber': str(kwargs.get('alternate_number', '')),
'AlternateCount': int(kwargs.get('alternate_count', -1)),
'Summary': str(kwargs.get('summary', '')),
'Notes': str(kwargs.get('notes', '')),
'Year': int(kwargs.get('year', -1)),
'Month': int(kwargs.get('month', -1)),
'Day': int(kwargs.get('day', -1)),
'Writer': str(kwargs.get('writer', '')),
'Penciller': str(kwargs.get('penciller', '')),
'Inker': str(kwargs.get('inker', '')),
'Colorist': str(kwargs.get('colorist', '')),
'Letterer': str(kwargs.get('letterer', '')),
'CoverArtist': str(kwargs.get('cover_artist', '')),
'Editor': str(kwargs.get('editor', '')),
'Translator': str(kwargs.get('translator', '')),
'Publisher': str(kwargs.get('publisher', '')),
'Imprint': str(kwargs.get('imprint', '')),
'Genre': str(kwargs.get('genre', '')),
'Tags': str(kwargs.get('tags', '')),
'Web': str(kwargs.get('web', '')),
'PageCount': len(self.__pages),
'LanguageISO': language(kwargs.get('language_iso', '')),
'Format': Format(kwargs.get('format', Format.UNKNOWN)),
'EAN': str(kwargs.get('ean', '')),
'BlackAndWhite': YesNo(kwargs.get('black_white', YesNo.UNKNOWN)),
'Manga': Manga(kwargs.get('manga', Manga.UNKNOWN)),
'Characters': str(kwargs.get('characters', '')),
'Teams': str(kwargs.get('teams', '')),
'Locations': str(kwargs.get('locations', '')),
'ScanInformation': str(kwargs.get('scan_information', '')),
'StoryArc': str(kwargs.get('story_arc', '')),
'StoryArcNumber': str(kwargs.get('story_arc_number', '')),
'SeriesGroup': str(kwargs.get('series_group', '')),
'AgeRating': AgeRating(kwargs.get('age_rating', AgeRating.UNKNOWN)),
'Pages': [{'Image': i, **page.dumps()} for i, page in enumerate(pages)],
'CommunityRating': rating(kwargs.get('community_rating', -1)),
'MainCharacterOrTeam': str(kwargs.get('main_character_or_team', '')),
'Review': str(kwargs.get('review', ''))
}
def dumps(self) -> dict:
return utils.dumps(self.__info)
def __repr__(self) -> str:
return json.dumps(self.dumps(), indent=2)
@classmethod
def from_pages(cls, pages: [PageInfo], **kwargs) -> ComicInfo:
return cls(pages, **kwargs)
@classmethod
def from_cbz(cls, path: Union[Path, str]) -> ComicInfo:
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
with Path(path).open(mode='rb') as f:
return cls(cls.__unpack(f.read()))
def __unpack(self, data: bytes) -> tuple[[PageInfo], dict]:
raise NotImplemented
def pack(self) -> bytes:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_STORED) as zip_file:
zip_file.writestr(
'ComicInfo.xml',
xmltodict.unparse({'ComicInfo': self.dumps()}, pretty=True).encode('utf-8')
)
for i, page in enumerate(self.__pages):
zip_file.writestr(f'page-{i + 1:03d}{page.suffix}', page.content)
return zip_buffer.getvalue()
+100
View File
@@ -0,0 +1,100 @@
from enum import Enum
class YesNo(Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
class Manga(Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
RIGHT_LEFT = 'YesAndRightToLeft'
class PageType(Enum):
FRONT_COVER = 'FrontCover'
INNER_COVER = 'InnerCover'
ROUNDUP = 'Roundup'
STORY = 'Story'
ADVERTISEMENT = 'Advertisement'
EDITORIAL = 'Editorial'
LETTERS = 'Letters'
PREVIEW = 'Preview'
BACK_COVER = 'BackCover'
OTHER = 'Other'
DELETED = 'Deleted'
class AgeRating(Enum):
UNKNOWN = 'Unknown'
ADULTS18 = 'Adults Only 18+'
CHILDHOOD = 'Early Childhood'
EVERYONE = 'Everyone'
EVERYONE10 = 'Everyone 10+'
G = 'G'
KIDS = 'Kids to Adults'
M = 'M'
MA15 = 'MA15+'
MATURE17 = 'Mature 17+'
PG = 'PG'
R18 = 'R18+'
PENDING = 'Rating Pending'
TEEN = 'Teen'
X18 = 'X18+'
class Format(Enum):
UNKNOWN = 'Unknown'
# ONE_SHOT = '1 Shot'
# ONE_SHOT = '1/2',
# ONE_SHOT = '1-Shot'
ANNOTATION = 'Annotation'
# ANNOTATIONS = 'Annotations'
ANNUAL = 'Annual'
ANTHOLOGY = 'Anthology'
# BLACK_WHITE = 'B&W'
# BLACK_WHITE = 'B/W'
# BLACK_WHITE = 'B&&W'
BLACK_WHITE = 'Black & White'
# BOX_SET = 'Box Set'
BOX_SET = 'Box-Set'
CROSSOVER = 'Crossover'
DIRECTOR_CUT = 'Director\'s Cut'
EPILOGUE = 'Epilogue'
EVENT = 'Event'
FCBD = 'FCBD'
FLYER = 'Flyer'
# GIANT_SIZE = 'Giant'
# GIANT_SIZE = 'Giant Size'
GIANT_SIZE = 'Giant-Size'
GRAPHIC_NOVEL = 'Graphic Novel'
# HARDCOVER = 'Hardcover'
HARDCOVER = 'Hard-Cover'
# KING_SIZE = 'King'
# KING_SIZE = 'King Size'
KING_SIZE = 'King-Size'
LIMITED_SERIES = 'Limited Series'
MAGAZINE = 'Magazine'
NSFW = 'NSFW'
# ONE_SHOT = 'One Shot'
ONE_SHOT = 'One-Shot'
POINT1 = 'Point 1'
PREVIEW = 'Preview'
PROLOGUE = 'Prologue'
REFERENCE = 'Reference'
REVIEW = 'Review'
REVIEWED = 'Reviewed'
SCANLATION = 'Scanlation'
SCRIPT = 'Script'
SERIES = 'Series'
SKETCH = 'Sketch'
SPECIAL = 'Special'
# TRADE_PAPER_BACK = 'TPB'
TRADE_PAPER_BACK = 'Trade Paper Back'
# WEB_COMIC = 'WebComic'
WEB_COMIC = 'Web Comic'
# YEAR_ONE = 'Year 1'
YEAR_ONE = 'Year One'
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import base64
import json
from io import BytesIO
from typing import Union
from pathlib import Path
from PIL import Image
from cbz import utils
from cbz.constants import PageType
class PageInfo:
def __init__(self, content: bytes, **kwargs):
image = Image.open(BytesIO(content))
self.suffix = f'.{image.format.lower()}'
self.content = content
self.__info = {
# 'Image': 0,
'Type': PageType(kwargs.get('type', PageType.STORY)),
'DoublePage': bool(kwargs.get('double', False)),
'ImageSize': len(content),
'Key': str(kwargs.get('key', '')),
'Bookmark': str(kwargs.get('bookmark', '')),
'ImageWidth': int(image.width),
'ImageHeight': int(image.height)
}
def dumps(self) -> dict:
return utils.dumps(self.__info)
def __repr__(self) -> str:
return json.dumps(self.dumps(), indent=2)
@classmethod
def loads(cls, data: Union[str, bytes], **kwargs) -> PageInfo:
if isinstance(data, str):
data = base64.b64decode(data)
if not isinstance(data, bytes):
raise ValueError(f'Expecting Bytes or Base64 input, got {data!r}')
return cls(data, **kwargs)
@classmethod
def load(cls, path: Union[Path, str], **kwargs) -> PageInfo:
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
with Path(path).open(mode='rb') as f:
return cls(f.read(), **kwargs)
+5
View File
@@ -0,0 +1,5 @@
from enum import Enum
def dumps(data: dict) -> dict:
return {k: v.value if isinstance(v, Enum) else v for k, v in data.items() if v and v != -1 and v != 'Unknown'}
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
from cbz.comic import ComicInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
from cbz.page import PageInfo
PARENT = Path()
if __name__ == '__main__':
# @Info: Load comic pages
paths = list((PARENT / 'images').iterdir())
pages = [PageInfo.load(
path=path,
type=PageType.FRONT_COVER if i == 0 else PageType.BACK_COVER if i == len(paths) - 1 else PageType.STORY
) for i, path in enumerate(paths)]
# @Info: Load comic metadata
comic = ComicInfo.from_pages(
pages=pages,
title='T1 - Arrête de me chauffer, Nagatoro',
series='Arrête de me chauffer, Nagatoro',
number='1',
count=8,
volume=1,
summary='Nagatoro est en seconde. Pleine d\u2019assurance, joueuse, moqueuse, elle se d\u00e9couvre un jour un passe-temps favori : martyriser son \u201cSenpai\u201d, lyc\u00e9en de premi\u00e8re timide et mal dans sa peau. Nagatoro taquine, agace, aguiche, va parfois trop loin... mais qu\u2019a-t-elle vraiment derri\u00e8re la t\u00eate ? Et si derri\u00e8re ses moqueries elle cachait une v\u00e9ritable affection ? Et si finalement, ses farces permettaient \u00e0 Senpai de s\u2019affirmer ?',
year=2021,
month=3,
day=12,
writer='Nanashi',
inker='Nanashi',
editor='Noeve Grafx',
publisher='Noeve Grafx',
imprint='Noeve Grafx',
genre='Shonen',
web='http://www.izneo.com/en/manga/shonen/arrete-de-me-chauffer-nagatoro-37560/arrete-de-me-chauffer-nagatoro-86232',
language_iso='fr',
format=Format.PREVIEW,
black_white=YesNo.YES,
manga=Manga.RIGHT_LEFT,
age_rating=AgeRating.EVERYONE10,
community_rating=5,
ean='9782490676569'
)
# @Info: Pack cbz
cbz_content = comic.pack()
# @Info: Write cbz
cbz_path = PARENT / 'Arrête de me chauffer, Nagatoro.cbz'
cbz_path.write_bytes(cbz_content)

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 619 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 592 KiB

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 680 KiB

Before

Width:  |  Height:  |  Size: 590 KiB

After

Width:  |  Height:  |  Size: 590 KiB

Before

Width:  |  Height:  |  Size: 492 KiB

After

Width:  |  Height:  |  Size: 492 KiB

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 479 KiB

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 549 KiB

+42
View File
@@ -0,0 +1,42 @@
from setuptools import setup, find_packages
setup(
name='cbz',
version='3.2.0',
author='hyugogirubato',
author_email='hyugogirubato@gmail.com',
description='CBZ is a Python package designed to facilitate the creation, manipulation, and extraction of comic book archive files in the CBZ format.',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
url='https://github.com/hyugogirubato/cbz',
packages=find_packages(),
license='GPL-3.0-only',
keywords=[
'metadata',
'manga',
'comics',
'ebooks',
'cbz',
'webtoons'
],
classifiers=[
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers',
'Intended Audience :: End Users/Desktop',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Topic :: Utilities'
],
install_requires=[
'requests',
'xmltodict',
'langcodes',
'Pillow'
],
python_requires='>=3.7'
)
+5
View File
@@ -0,0 +1,5 @@
from .client import *
from .constants import *
from .utils import *
__version__ = '2.2.7'
+319
View File
@@ -0,0 +1,319 @@
import time
from urllib.parse import urlencode
from uuid import UUID
import requests
from requests import Response
from hidive.constants import View, Filter
from hidive.utils import jwt_expired, b64encode
class Client:
USER_AGENT = 'ExoDoris/2.2.7 (Linux;Android 10) AndroidXMedia3/1.0.2'
def __init__(self, access_token: str = None, refresh_token: str = None):
self.api = 'https://dce-frontoffice.imggaming.com/api'
self.guide = 'https://guide.imggaming.com'
self.algolia = 'https://h99xldr8mj-dsn.algolia.net'
self.user = {
'authorisationToken': access_token or '',
'refreshToken': refresh_token or ''
}
def __request(self, **kwargs) -> Response:
headers = {
'Accept': '*/*',
'User-Agent': 'okhttp/4.9.2',
**kwargs.get('headers', {})
}
url: str = kwargs.get('url')
params = kwargs.get('params', {})
if url.startswith(self.api) or url.startswith(self.guide):
headers['app'] = 'dice'
headers['realm'] = 'dce.hidive'
headers['x-api-key'] = '4dc1e8df-5869-41ea-95c2-6f04c67459ed'
headers['x-app-var'] = '2.2.7'
if kwargs.get('auth', True):
headers['Authorization'] = self.__token()
elif url.startswith(self.algolia):
params['x-algolia-agent'] = 'Algolia for JavaScript (3.35.1); React Native'
params['x-algolia-application-id'] = 'H99XLDR8MJ'
params['x-algolia-api-key'] = 'e55ccb3db0399eabe2bfc37a0314c346'
r = requests.request(
method=kwargs.get('method', 'GET'),
url=url,
params=params,
json=kwargs.get('json'),
data=kwargs.get('data'),
headers=headers
)
r.raise_for_status()
return r
def __token(self) -> str:
access_token = self.user.get('authorisationToken')
if not access_token:
self.guest()
elif jwt_expired(access_token):
self.refresh()
return 'Bearer %s' % self.user['authorisationToken']
# @package: login
def guest(self) -> dict:
content = self.__request(method='POST', url=f'{self.api}/v2/login/guest/checkin', auth=False).json()
self.user.update(content)
return content
def refresh(self) -> dict:
refresh_token = self.user['refreshToken']
if jwt_expired(refresh_token):
raise ValueError('Refresh token expired')
content = self.__request(
method='POST',
url=f'{self.api}/v2/token/refresh',
json={'refreshToken': refresh_token}
).json()
self.user.update(content)
return content
def login(self, email: str, password: str) -> dict:
content = self.__request(
method='POST',
url=f'{self.api}/v2/login',
json={'id': email, 'secret': password}
).json()
self.user.update(content)
return content
def reset(self, email: str) -> dict:
return self.__request(
method='POST',
url=f'{self.api}/v2/reset-password/create',
json={'id': email, 'provider': 'ID'},
auth=False
).json()
def create(self, email: str, password: str) -> dict:
content = self.__request(
method='POST',
url=f'{self.api}/v2/user',
json={
'email': email,
'secret': password,
'consentAnswers': [{
'answer': f['required'],
'promptField': f['fieldName']
} for f in self.consent()['fields']]
}
).json()
self.user.update(content)
return content
# @package: realm
def providers(self) -> list[dict]:
return self.__request(
method='GET',
url=f'{self.api}/v2/realm/authentication-providers'
).json()['authenticationProviders']
def settings(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/realm-settings/realm/dce.hidive').json()
def label(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/label/dce.hidive').json()
def licence(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/licence').json()
def consent(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/consent-prompt').json()
# @package: user
def preferences(self, auto_play: bool = None) -> dict:
method = 'GET' if auto_play is None else 'PUT'
data = None if auto_play is None else {'autoAdvance': auto_play}
return self.__request(method=method, url=f'{self.api}/v2/user/preferences', json=data).json()
def profile(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/user/profile').json()
def notification(self, limit: int = 1) -> dict:
return self.__request(
method='GET',
url=f'{self.api}/v2/promo-notification',
params={'maxNotifications': limit}
).json()
def watch_create(self, name: str) -> dict:
return self.__request(
method='POST',
url=f'{self.api}/v3/user/watchlist',
json={'name': name}
).json()
def watch_delete(self, watch_id: int) -> None:
href = b64encode('480211|dce.hidive')
self.__request(method='DELETE', url=f'{self.api}/v3/user/watchlist/{href}/{watch_id}')
def watch(self, limit: int = 25) -> dict:
return self.__request(method='GET', url=f'{self.api}/v3/user/watchlist', params={'rpp': limit}).json()
def watch_add(self, watch_id: int, content_type: str, content_id: int) -> dict:
return self.__request(
method='POST',
url=f'{self.api}/v4/user/watchlist/{watch_id}/content',
json={'content': [{'contentType': content_type, 'id': content_id}]}
).json()
def watch_remove(self, watch_id: int, content_type: str, content_id: int) -> None:
self.__request(
method='DELETE',
url=f'{self.api}/v4/user/watchlist/{watch_id}/content/{content_type}/{content_id}'
)
def watch_details(self, watch_id: int, limit: int = 25) -> dict:
return self.__request(
method='GET',
url=f'{self.api}/v4/user/watchlist/{watch_id}',
params={'rpp': limit}
).json()
# @package: content
def menu(self) -> list[dict]:
return self.__request(method='GET', url=f'{self.api}/v2/menu-items').json()
def event(self, limit: int = 20) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/event/live', params={'rpp': limit}).json()
def content(self, title: str = 'home') -> dict:
# @Default: home | browse
return self.__request(
method='GET',
url=f'{self.api}/v4/content/{title}',
params={
'bpp': 10,
'rpp': 12,
'displaySectionLinkBuckets': 'SHOW',
'displayEpgBuckets': 'SHOW',
'displayEmptyBucketShortcuts': 'SHOW',
'displayGeoblocked': 'SHOW',
'bspp': 20
}
).json()
def popular(self) -> dict:
return self.__request(method='GET', url=f'{self.api}/v2/popular').json()
def view(self, view: View, view_id: int) -> dict:
return self.__request(
method='GET',
url=f'{self.api}/v1/view',
params={'type': view.value, 'id': view_id}
).json()
def series(self, series_id: int, limit: int = 1) -> dict:
return self.__request(method='GET', url=f'{self.api}/v4/series/{series_id}', params={'rpp': limit}).json()
def query(
self,
query: str,
facets: list = None,
facet_filters: list = None,
page: int = 0,
filters: Filter = None
) -> dict:
params = {
'facets': facets or [],
'query': query,
'facetFilters': facet_filters or []
}
if filters:
params['filters'] = f'type:{filters.value}'
else:
params['page'] = page
return self.__request(
method='POST',
url=f'{self.algolia}/1/indexes/prod-dce.hidive-livestreaming-events/query',
json={'params': urlencode(params)}
).json()
# @package: vod
def live(self, video_id: int) -> dict:
return self.__request(method='GET', url=f'{self.api}/v4/vods/live/{video_id}').json()
def stream(self, video_id: int) -> dict:
return self.__request(method='GET', url=f'{self.api}/v3/stream/vod/{video_id}').json()
def details(self, video_id: int, playback: bool = True) -> dict:
return self.__request(
method='GET',
url=f'{self.api}/v2/vod/{video_id}',
params={'includePlaybackDetails': 'URL'} if playback else None,
headers={
'cm-app-bundle': 'com.hidive.android',
'cm-app-name': 'HIDIVE',
'cm-app-storeid': 'com.hidive.android',
'cm-app-version': '2.2.7',
'cm-cst-ifa': '',
'cm-cst-lat': '0',
'cm-cst-tcf': '',
'cm-cst-usp': '',
'cm-dvc-dnt': '0',
'cm-dvc-h': '720',
'cm-dvc-lang': 'en_US',
'cm-dvc-make': 'Xiaomi',
'cm-dvc-model': 'Mi A2',
'cm-dvc-os': '2',
'cm-dvc-osv': '10',
'cm-dvc-type': '4',
'cm-dvc-w': '360'
}
).json()
def adjacent(self, video_id: int, limit: int = 20) -> dict:
return self.__request(
method='GET',
url=f'{self.api}/v4/vod/{video_id}/adjacent',
params={'size': limit}
).json()
# @package: player
def progress(self, video_id: int, cid: str, progress: int) -> dict:
return self.__request(
method='PUT',
url=f'{self.guide}/prod',
params={
'action': 2,
'cid': cid,
'nature': 'last',
'progress': progress,
'startedAt': round(time.time()),
'video': video_id
}
).json()
def playback(self, url: str) -> dict:
return self.__request(method='GET', url=url, auth=False).json()
def drm(self, url: str, token: str, key_ids: list[UUID], challenge: bytes) -> bytes:
# @Info: widevine DRM
return self.__request(
method='POST',
url=url,
data=challenge,
headers={
'Authorization': f'Bearer {token}',
'User-Agent': 'Dice Shield/2.2.7 (Linux;Android 10) AndroidXMedia3/1.0.2',
'X-DRM-INFO': b64encode({
'system': 'com.widevine.alpha',
'key_ids': [str(k) for k in key_ids]
})
}
).content
+20
View File
@@ -0,0 +1,20 @@
from enum import Enum
class Filter(Enum):
SERIES = 'VOD_SERIES'
VIDEO = 'VOD_VIDEO'
EVENT = 'LIVE_EVENT'
PLAYLIST = 'VOD_PLAYLIST'
class Format(Enum):
VTT = 'vtt'
SRT = 'srt'
SCC = 'scc'
class View(Enum):
SEASON = 'season'
PLAYLIST = 'playlist'
VOD = 'VOD'
+31
View File
@@ -0,0 +1,31 @@
import base64
import json
import re
import time
from typing import Union
def b64decode(value: str) -> bytes:
return base64.b64decode(value + '=' * (4 - len(value) % 4))
def b64encode(value: Union[str, bytes, dict, list]) -> str:
if isinstance(value, (dict, list)):
value = json.dumps(value, separators=(',', ':'))
if isinstance(value, str):
value = value.encode('utf-8')
return base64.b64encode(value).decode('utf-8')
def jwt_expired(value: str) -> bool:
header, payload, signature = value.split('.', 2)
payload = json.loads(b64decode(payload))
return payload['exp'] < round(time.time())
def parse_pssh(value: str) -> str:
lines = value.splitlines()
for i, line in enumerate(lines):
if 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed' in line:
return re.search(r'<cenc:pssh>(.+)</cenc:pssh>', lines[i + 1]).group(1)
raise ValueError('Could not find pssh')
+88
View File
@@ -0,0 +1,88 @@
import requests
from pathlib import Path
from hidive.client import Client
from hidive.constants import Format, View
from hidive.utils import parse_pssh, b64encode
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
DEVICE = Path() / 'DEVICE.wvd'
if __name__ == '__main__':
client = Client()
client.login(email='EMAIL', password='PASSWORD')
# @Info: series
series = client.series(series_id=1049)
print('[+] Series: %s' % series['title'])
print('[+] Cover: %s' % series['coverUrl'])
print('[+] Poster: %s' % series['posterUrl'])
# @Info: seasons
for view in series['seasons']:
print('[+] Season: %s' % view['seasonNumber'])
season = client.view(view=View.SEASON, view_id=view['id'])
# @Info: episodes
bucket = next(e for e in season['elements'] if e['$type'] == 'bucket')
for episode in bucket['attributes']['items']:
video_id = episode['id']
print('[+] Title: %s' % episode['title'])
print('[+] Description: %s' % episode['description'])
details = client.details(video_id=video_id, playback=True)
print('[+] Thumbnail: %s' % details['thumbnailUrl'])
# @Info: subtitles
playback = client.playback(url=details['playerUrlCallback'])
dash = playback['dash'][0]
for subtitle in dash['subtitles']:
if subtitle['format'] == Format.VTT:
print('[+] Subtitle (%s): %s' % (subtitle['language'], subtitle['url']))
# @Info: widevine DRM
manifest_url = dash['url']
print(f'[+] Manifest: {manifest_url}')
assert 'WIDEVINE' in dash['drm']['keySystems'], 'Unsupported DRM system'
manifest = requests.request(method='GET', url=manifest_url, headers={'User-Agent': Client.USER_AGENT}).text
pssh = PSSH(parse_pssh(manifest))
print(f'[+] PSSH: {pssh}')
device = Device.load(DEVICE)
cdm = Cdm.from_device(device)
session_id = cdm.open()
challenge = cdm.get_license_challenge(session_id, pssh)
print('[+] Challenge: %s' % b64encode(challenge))
licence = client.drm(
url=dash['drm']['url'],
token=dash['drm']['jwtToken'],
key_ids=pssh.key_ids,
challenge=challenge
)
print(f'[+] Licence: {b64encode(licence)}')
cdm.parse_license(session_id, licence)
keys = cdm.get_keys(session_id, type_='CONTENT')
if not keys:
raise ValueError('Could not find key')
keys = [f'{k.kid.hex}:{k.key.hex()}' for k in keys]
cdm.close(session_id)
print('[+] Keys: %s' % '|'.join(keys))
# @Info: download
print('[+] Prompt: %s' % ' '.join([
'N_m3u8DL-RE',
f'"{manifest_url}"',
*[f'--key "{k}"' for k in keys],
'--header',
f'"User-Agent: {Client.USER_AGENT}"'
]))
exit(0)
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.10" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/profiles.iml" filepath="$PROJECT_DIR$/.idea/profiles.iml" />
</modules>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show More