Release v3.2.0
@@ -1,73 +0,0 @@
|
|||||||
# PyCBZHelper
|
|
||||||
|
|
||||||
[](https://github.com/hyugogirubato/pycbzhelper/blob/main/LICENSE)
|
|
||||||
[](https://github.com/hyugogirubato/pycbzhelper/releases)
|
|
||||||
[](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).
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CBZ
|
||||||
|
|
||||||
|
[](https://github.com/hyugogirubato/cbz/blob/main/LICENSE)
|
||||||
|
[](https://github.com/hyugogirubato/cbz/releases)
|
||||||
|
[](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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .comic import ComicInfo
|
||||||
|
from .constants import *
|
||||||
|
from .page import PageInfo
|
||||||
|
|
||||||
|
__version__ = '3.2.0'
|
||||||
@@ -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()
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
@@ -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'}
|
||||||
@@ -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 |
@@ -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'
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .client import *
|
||||||
|
from .constants import *
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
__version__ = '2.2.7'
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 28 KiB |