diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ed98fc5..11bf318 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,4 +1,4 @@ -name: Deploy and Release +name: Publish on: push: @@ -29,30 +29,24 @@ jobs: with: python-version: "3.x" + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools wheel - pip install build twine - pip install -r requirements.txt + make setup + uv pip install twine - name: Build the package - run: python -m build --no-isolation + run: uv build --link-mode copy - name: List dist directory run: ls -l dist/ - - name: Create Release - uses: ncipollo/release-action@v1 - with: - artifacts: dist/*.tar.gz,dist/*.whl - tag: ${{ github.ref_name }} - name: Release ${{ github.ref_name }} - draft: false - prerelease: false - - name: Check distribution's long description rendering on PyPI - run: twine check dist/* + run: uv run twine check dist/* - name: Publish package - if: github.repository == 'xMohnad/MangaDM' uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.python-version b/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e50ae22..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -graft src/mangadm -global-exclude *.py[cod] -global-exclude __pycache__/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a9469db --- /dev/null +++ b/Makefile @@ -0,0 +1,96 @@ +lib := mangadm +src := src/ +run := uv run +sync := uv sync +build := uv build +python := $(run) python +ptpython := $(run) ptpython +ruff := $(run) ruff +lint := $(ruff) check --select I +fmt := $(ruff) format +basedpyright := $(run) basedpyright +spell := $(run) codespell + +############################################################################## +# Local "interactive testing" of the code. +.PHONY: run +run: # Run the code with debug enabled + $(python) -m $(lib) -h + + +############################################################################## +# Setup/update packages the system requires. +.PHONY: setup +setup: # Set up the repository for development + uv venv --allow-existing + $(sync) + +.PHONY: update +update: # Update all dependencies + $(sync) --upgrade + +.PHONY: resetup +resetup: realclean # Recreate the virtual environment from scratch + make setup + +############################################################################## +# Checking/testing/linting/etc. +.PHONY: lint +lint: # Check the code for linting issues + $(lint) $(src) + +.PHONY: codestyle +codestyle: # Is the code formatted correctly? + $(fmt) --check $(src) + +.PHONY: typecheck +typecheck: # Perform static type checks with basedpyright + $(basedpyright) $(src) + +.PHONY: spellcheck +spellcheck: # Spell check the code + $(spell) *.md $(src) + +.PHONY: checkall +checkall: spellcheck codestyle lint stricttypecheck # Check all the things + +############################################################################## +# Package +.PHONY: package +package: # Package the library + $(build) + +.PHONY: spackage +spackage: # Create a source package for the library + $(build) --sdist + +############################################################################## +# Utility. +.PHONY: repl +repl: # Start a ptPython REPL in the venv. + $(run) $(ptpython) + +.PHONY: delint +delint: # Fix linting issues. + $(lint) --fix $(src) + +.PHONY: pep8ify +pep8ify: # Reformat the code to be as PEP8 as possible. + $(fmt) $(src) + +.PHONY: tidy +tidy: delint pep8ify # Tidy up the code, fixing lint and format issues. + +.PHONY: clean +clean: # Clean the package building files + rm -rf dist/ build/ $(src)*.egg-info .pytest_cache + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + find . -name "*.pyc" -delete + +.PHONY: realclean +realclean: clean # Clean the venv and build directories + rm -rf .venv + +.PHONY: help +help: # Display this help + @grep -Eh "^[a-z]+:.+# " $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.+# "}; {printf "%-20s %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 816e702..747de9b 100644 --- a/README.md +++ b/README.md @@ -3,171 +3,24 @@ [](./LICENSE) [](https://deepwiki.com/xMohnad/MangaDM) [](https://pypi.python.org/pypi/MangaDM) +[](https://pypi.python.org/pypi/MangaDM) -**MangaDM** is a command-line tool and Python library for downloading manga chapters based on the metadata specified in JSON files. +**MangaDM** is a powerful command-line tool for downloading manga from JSON metadata file. -## Table of Contents - -- [Installation](#installation) - - - [Install from PyPI](#install-from-pypi) - - [Install from Source](#install-from-source) - -- [CLI Usage](#cli-usage) - - - [Commands](#commands) - - [download](#download) - - [configure](#configure) - - [example](#example) - - [view](#view) - - [reset](#reset) - - [tui](#tui) - - [completion](#completion) - - [General Notes](#general-notes) - -- [Library Usage](#library-usage) - - - [Example](#example-usage) - - [Parameters](#parameters) - -- [JSON Structure](#json-structure) - - - [Structure Breakdown](#structure-breakdown) - -- [Contributing](#contributing) - - - [Bug Reports & Feature Requests](#bug-reports-and-feature-requests) - -______________________________________________________________________ +--- ## Installation -Ensure you have Python installed before proceeding. - -______________________________________________________________________ - -### Install from PyPI - ```sh pip install MangaDM ``` -______________________________________________________________________ - -### Install from Source - -To install MangaDM directly from the source code: - -1. **Clone the Repository** - - ```sh - git clone https://github.com/xMohnad/MangaDM.git - cd MangaDM - ``` - -1. **Install MangaDM** - - ```sh - pip install . - ``` - -______________________________________________________________________ - -## CLI Usage - -Once installed, you can use the `mangadm` command to download manga chapters. - -### Commands - -#### `download` - -- **Purpose**: Downloads manga chapters based on a provided JSON file. -- **Arguments**: - - `json_file`: Path to the JSON file containing manga download details. -- **Options**: - - `--dest, -p`: Destination path for downloading manga (default is the current directory). - - `--limit, -l`: Number of chapters to download (default is `-1` for all chapters). - - `--delete/--no-delete, -d`: Delete data after successful download. - - `--format, -f`: Format for downloaded manga (choices are based on `FormatType` enum). - - `--update-details/--no-update-details, -u`: Update `details.json` and re-download cover. - -#### `configure` - -- **Purpose**: Opens a configuration UI for setting up MangaDM options. - -#### `example` - -- **Purpose**: Displays an example JSON structure. - -#### `view` - -- **Purpose**: Views the current configuration settings. - -#### `reset` - -- **Purpose**: Reset saved settings. - -#### `tui` +## Command Line Interface -- **Purpose**: Open Textual TUI. - -### `completion` - -- **purpose**: the `completion` command group manages shell completion scripts for the cli tool. - -- **subcommands**: - - - `install`: installs shell autocompletion script for the specified shell. - - - **arguments**: - - `shell`: the shell type to install completion for (e.g., bash, zsh, fish). - - - `show`: displays the autocompletion script for inspection without installing. - - - **arguments**: - - `shell`: the shell type to show completion script for. - - - `shells`: lists all supported shell types for which completion is available. - -### General Notes - -- **Versioning**: The version is displayed with the `--version, -V` flag. -- **More**: Use `--help` flag in any of the commands. - -______________________________________________________________________ - -## Library Usage - -You can also use MangaDM as a Python library. Here’s how you can use it programmatically: - -### Example Usage - -```python -from mangadm import MangaDM, FormatType - -# Create an instance of MangaDM with desired parameters -mangadm = MangaDM( - json_file="path/to/yourfile.json", - dest_path="path/to/chapter", - limit=1, - delete_on_success=False, - format=FormatType.cbz, -) - -# Start the downloading process -mangadm.start() +```bash +mangadm --help ``` -### Parameters - -- `json_file` (Path): The path to the JSON file containing manga data. -- `dest_path` (str): The destination path where manga chapters will be downloaded. Defaults to the current directory. -- `limit` (int): Number of chapters to download. If -1, download all chapters. Defaults to -1. -- `delete_on_success` (bool): If `True`, delete chapter data from JSON after successful download. Defaults to False. -- `format` (FormatType): The format in which to save the downloaded manga. - -______________________________________________________________________ - ## JSON Structure Below is an example of the JSON structure required for the input file: @@ -175,17 +28,17 @@ Below is an example of the JSON structure required for the input file: ```json { "details": { - "source": "Example Source Name", - "manganame": "Example Manga Name", + "source": "Source Name", + "title": "Manga Name", "cover": "https://example.com/cover.jpg", - "description": "Example Description", - "genre": ["genre 1", "genre 2", "etc"], - "author": "Akutami Gege", - "artist": "Akutami Gege" + "description": "Description", + "genres": ["genre 1", "genre 2", "etc"], + "author": "string|null", + "artist": "string|null" }, "chapters": [ { - "title": "chapter 256 - Example Title", + "title": "chapter 256 - Title", "images": [ "https://example.com/image1.jpg", "https://example.com/image2.jpg", @@ -195,17 +48,7 @@ Below is an example of the JSON structure required for the input file: ] }, { - "title": "chapter 257 - Example Title", - "images": [ - "https://example.com/image1.jpg", - "https://example.com/image2.jpg", - "https://example.com/image3.jpg", - "https://example.com/image4.jpg", - "etc" - ] - }, - { - "title": "chapter 258 - Example Title", + "title": "chapter 257 - Title", "images": [ "https://example.com/image1.jpg", "https://example.com/image2.jpg", @@ -218,28 +61,6 @@ Below is an example of the JSON structure required for the input file: } ``` -The JSON structure provided above illustrates the format expected for input files used by the MangaDM tool. Each JSON file should follow these guidelines: - -### Structure Breakdown - -- **`details`**: Contains metadata about the manga series. - - **`source`**: The source or website where the manga is found. - - **`manganame`**: The name of the manga series. - - **`cover`**: A URL pointing to the cover image of the manga. This is optional but can enhance the metadata. - - **`description`**: A short description of the manga. - - **`genre`**: A list of genres the manga belongs to. - - **`author`**: The name of the author of the manga. - - **`artist`**: The name of the artist responsible for the manga's artwork. -- **`chapters`**: An array of objects, each representing a chapter in the manga. - - **`title`**: The title or number of the chapter. - - **`images`**: A list of URLs where each URL points to an image file, typically representing the pages of the manga chapter. - -______________________________________________________________________ - ## Contributing -### Bug Reports and Feature Requests - -If you encounter any issues while using MangaDM or have suggestions for new features, -you can open an issue on the GitHub repository. -Please provide detailed information about the issue or feature request to help in resolving it more effectively. +Contributions are welcome! If you'd like to contribute, please fork the repository and submit a pull request. For major changes, please open an issue first to discuss the proposed changes. diff --git a/pyproject.toml b/pyproject.toml index 1e76b12..d2d4051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,20 @@ [project] name = "MangaDM" -dynamic = ["version"] -description = "A command-line tool and Python library for downloading manga chapters based on the metadata specified in a JSON file." +version = "0.6.0" +description = "A tool for downloading manga." authors = [{ name = "xMohnad" }] readme = "README.md" -license = { file = "LICENSE" } -requires-python = ">=3.12" +license = "MIT" +requires-python = ">=3.10" dependencies = [ "aiohttp>=3.12.13", - "click>=8.2.1", - "click-completion>=0.5.2", + "dacite>=1.9.2", "ebooklib>=0.19", "inquirerpy>=0.3.4", + "pyyaml>=6.0.2", "rich>=14.0.0", - "trogon>=0.6.0", + "typer>=0.19.2", ] classifiers = [ @@ -22,36 +22,33 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Terminals", - "Topic :: Software Development :: Libraries", "Typing :: Typed", ] -keywords = ["terminal"] - [project.urls] -Repository = "https://github.com/xMohnad/MangaDM" -Source = "https://github.com/xMohnad/MangaDM" +Homepage = "https://github.com/xMohnad/MangaDM" Issues = "https://github.com/xMohnad/MangaDM/issues" -Discussions = "https://github.com/xMohnad/MangaDM/discussions" -[tool.setuptools] -include-package-data = true +[build-system] +requires = ["uv_build>=0.8.11,<0.10.0"] +build-backend = "uv_build" + +[project.scripts] +mangadm = "mangadm.cli:app" [tool.uv] package = true -[build-system] -requires = ["setuptools>=61.0.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.dynamic] -version = { attr = "mangadm.__version__" } +[tool.pyright] +reportUnusedCallResult = false -[tool.hatch.build.targets.wheel] -packages = ["src/mangadm"] +[tool.ruff] +line-length = 120 -[project.scripts] -mangadm = "mangadm.cli.main:cli" +[dependency-groups] +dev = ["codespell>=2.4.1", "ruff>=0.12.9", "ptpython>=3.0.0"] diff --git a/src/mangadm/__init__.py b/src/mangadm/__init__.py index b3867fc..24d6c5f 100644 --- a/src/mangadm/__init__.py +++ b/src/mangadm/__init__.py @@ -1,25 +1,8 @@ -from typing import TYPE_CHECKING +from importlib.metadata import version +from pathlib import Path -if TYPE_CHECKING: - from typing import Any +import typer - from .components.types import FormatType as FormatTypeType - from .core.mangadm import MangaDM as MangaDMType - -MangaDM: "type[MangaDMType]" -FormatType: "type[FormatTypeType]" - -__version__ = "0.6.0" -__all__ = ["MangaDM", "FormatType"] - - -def __getattr__(name: str) -> "Any": - if name == "MangaDM": - from .core.mangadm import MangaDM - - return MangaDM - if name == "FormatType": - from .components.types import FormatType - - return FormatType - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") +__version__ = version(__name__) +__config__: Path = Path(typer.get_app_dir(__name__)) / "config.yaml" +__config__.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/mangadm/__main__.py b/src/mangadm/__main__.py index ac9f281..492d501 100644 --- a/src/mangadm/__main__.py +++ b/src/mangadm/__main__.py @@ -1,4 +1,4 @@ -from mangadm.cli.main import cli +from mangadm.cli import app if __name__ == "__main__": - cli() + app() diff --git a/src/mangadm/archiver.py b/src/mangadm/archiver.py new file mode 100644 index 0000000..204c424 --- /dev/null +++ b/src/mangadm/archiver.py @@ -0,0 +1,90 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +import logging +import shutil +import zipfile +from pathlib import Path +from typing import Callable + +from ebooklib import epub # pyright: ignore[reportMissingTypeStubs] + +from mangadm.assets import build_chapter_content +from mangadm.schema.formats import FormatType + +IMAGE_EXTENSIONS: list[str] = [".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".png"] + + +class MangaArchiver: + """Utility class to create manga archives in different formats (CBZ, EPUB).""" + + logger: logging.Logger = logging.getLogger(__name__) + + def get_image_paths(self, folder_path: Path) -> list[Path]: + """Return sorted list of image paths from the given folder.""" + return sorted([file for file in folder_path.absolute().rglob("*") if file.suffix.lower() in IMAGE_EXTENSIONS]) + + def _make_file(self, folder: Path, format: FormatType) -> Path: + """Return a Path for the output archive file based on format.""" + return folder.with_suffix(f".{format.value}") + + def create_cbz(self, folder_path: Path, **_: object) -> None: + """Create a CBZ (Comic Book ZIP) file from a folder and delete the original folder.""" + folder = folder_path.resolve() + cbz_file = self._make_file(folder, FormatType.cbz) + try: + with zipfile.ZipFile(cbz_file, "w", zipfile.ZIP_DEFLATED) as cbz: + for file_path in self.get_image_paths(folder): + cbz.write(file_path, file_path.relative_to(folder)) + self.logger.debug("Added image '%s' to CBZ", file_path.name) + + shutil.rmtree(folder) + self.logger.info("CBZ archive '%s' created successfully and folder removed", cbz_file.name) + except (IOError, OSError): + self.logger.exception(f"Error creating `{cbz_file.name}`") + + def create_epub(self, folder_path: Path, title: str = "", **_: object) -> None: + """Create an EPUB file from a folder of images.""" + folder = folder_path.resolve() + epub_file = self._make_file(folder, FormatType.epub) + + book = epub.EpubBook() + book.set_title(title) + + image_paths = self.get_image_paths(folder) + chapter = epub.EpubHtml( + title=title, + file_name="chapter.xhtml", + content=build_chapter_content(image_paths), + ) + book.add_item(chapter) + + # Add images as resources to the book + for image_path in image_paths: + book.add_item(epub.EpubImage(file_name=f"images/{image_path.name}", content=image_path.read_bytes())) + self.logger.debug("Added image '%s' to EPUB", image_path.name) + + book.spine = ["nav", chapter] + + try: + epub.write_epub(epub_file, book) + shutil.rmtree(folder) + self.logger.info("EPUB archive '%s' created successfully and folder removed", epub_file.name) + except (IOError, OSError) as e: + self.logger.exception("Error creating EPUB '%s': %r", epub_file.name, e) + + def create_archive(self, folder: Path, format: FormatType, title: str = "") -> None: + """Dispatch to the correct archive creation method based on format. + + Args: + folder (Path): Folder containing image files. + format (FormatType): Target archive format (e.g., cbz or epub). + title (str, optional): Optional title for formats that support it. + """ + archiver: Callable[..., None] | None = getattr(self, f"create_{format.value}", None) + if callable(archiver): + self.logger.info("Starting archive creation for '%s' as %s", folder, format.value) + archiver(folder_path=folder, title=title) + else: + self.logger.warning("No archiver found for format '%s'", format.value) diff --git a/src/mangadm/assets/__init__.py b/src/mangadm/assets/__init__.py index 30ad3f6..0ab2c69 100644 --- a/src/mangadm/assets/__init__.py +++ b/src/mangadm/assets/__init__.py @@ -1,32 +1,36 @@ from pathlib import Path -ROOT = Path(__file__).parent +ROOT: Path = Path(__file__).parent -def image_error() -> bytes: - return (ROOT / "not_available_error.webp").read_bytes() -def epub_style_css() -> bytes: - return (ROOT / "style.css").read_bytes() +def placeholder_image() -> bytes: + """Return the bytes of a default placeholder image. -def build_chapter_content(image_paths): + Used to replace missing or unavailable manga images during download. + + Returns: + bytes: Contents of the placeholder image file. + """ + return (ROOT / "placeholder_image.webp").read_bytes() + + +def build_chapter_content(image_paths: list[Path]) -> str: """Build the HTML content for the EPUB chapter with all images.""" - content = ''' + content = """
- - '''.format(style="style.css") + """ # Add image tags to the content for image_path in image_paths: - filename = Path(image_path).name + filename = image_path.name content += f'