Compare commits

..

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

77 changed files with 2074 additions and 4198 deletions

25
.bumpversion.cfg Normal file
View file

@ -0,0 +1,25 @@
[bumpversion]
current_version = 2.1.10
commit = True
tag = False
serialize = {major}.{minor}.{patch}
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:manga-dlp.py]
search = MDLP_VERSION = "{current_version}"
replace = MDLP_VERSION = "{new_version}"
[bumpversion:file:mangadlp/input.py]
search = MDLP_VERSION = "{current_version}"
replace = MDLP_VERSION = "{new_version}"
[bumpversion:file:.woodpecker/test_docker.yml]
search = BUILD_VERSION={current_version}
replace = BUILD_VERSION={new_version}
[bumpversion:file:.woodpecker/test_release.yml]
search = {current_version}
replace = {new_version}

1
.envrc Normal file
View file

@ -0,0 +1 @@
use asdf

View file

@ -1,99 +0,0 @@
name: build package and container
on:
push:
tags:
- "v*.*.*"
pull_request:
branches: [main, master]
jobs:
build-pypackage:
runs-on: python311
env:
HATCH_INDEX_REPO: main
HATCH_INDEX_USER: __token__
HATCH_INDEX_AUTH: ${{ secrets.PYPI_TOKEN }}
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch hatchling
- name: build package
run: hatch build --clean
- name: publish package
if: gitea.event_name != 'pull_request'
run: hatch publish --yes --no-prompt
build-container:
runs-on: ubuntu-latest
env:
REGISTRY: docker.io
AUTHOR: olofvndrhr
IMAGE: manga-dlp
steps:
- name: checkout code
uses: actions/checkout@v3
- name: setup qemu
uses: docker/setup-qemu-action@v2
- name: setup docker buildx
uses: docker/setup-buildx-action@v2
- name: get container metadata
uses: docker/metadata-action@v4
id: metadata
with:
images: ${{ env.REGISTRY }}/${{ env.AUTHOR }}/${{ env.IMAGE }}
flavor: |
latest=auto
prefix=
suffix=
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: login to docker.io container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}
- name: login to private container registry
uses: docker/login-action@v2
with:
registry: git.44net.ch
username: ${{ secrets.CR_PRIV_USERNAME }}
password: ${{ secrets.CR_PRIV_PASSWORD }}
- name: build and push docker image @amd64+arm64
uses: docker/build-push-action@v4
with:
push: ${{ gitea.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
context: .
file: docker/Dockerfile
provenance: false
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
- name: update dockerhub repo description
uses: peter-evans/dockerhub-description@v3
if: gitea.event_name != 'pull_request'
with:
repository: ${{ env.AUTHOR }}/${{ env.IMAGE }}
short-description: ${{ github.event.repository.description }}
enable-url-completion: true
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}

View file

@ -1,122 +0,0 @@
name: check code
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
check-docs:
runs-on: python311
steps:
- name: checkout code
uses: actions/checkout@v3
- name: "build docs"
run: |
python3 -m pip install mkdocs
cd docs || exit 1
mkdocs build --strict
scan-code-py311:
runs-on: python311
if: gitea.event_name != 'pull_request'
needs: [check-code-py38]
steps:
- name: checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: install hatch
run: pip install -U hatch
- name: get coverage (hatch)
run: hatch run default:cov
- name: run sonar-scanner
uses: sonarsource/sonarqube-scan-action@v2.1.0
env:
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST }}
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
check-code-py38:
runs-on: python38
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: test codestyle
run: hatch run +py=3.8 lint:style
- name: test typing
run: hatch run +py=3.8 lint:typing
- name: run tests
if: gitea.event_name == 'pull_request'
run: hatch run default:test
check-code-py39:
runs-on: python39
needs: [check-code-py38]
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: test codestyle
run: hatch run +py=3.9 lint:style
- name: test typing
run: hatch run +py=3.9 lint:typing
- name: run tests
if: gitea.event_name == 'pull_request'
run: hatch run default:test
check-code-py310:
runs-on: python310
needs: [check-code-py39]
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: test codestyle
run: hatch run +py=3.10 lint:style
- name: test typing
run: hatch run +py=3.10 lint:typing
- name: run tests
if: gitea.event_name == 'pull_request'
run: hatch run default:test
check-code-py311:
runs-on: python311
needs: [check-code-py310]
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: test codestyle
run: hatch run +py=3.11 lint:style
- name: test typing
run: hatch run +py=3.11 lint:typing
- name: run tests
if: gitea.event_name == 'pull_request'
run: hatch run default:test

View file

@ -1,56 +0,0 @@
name: create release
on:
push:
tags:
- "v*.*.*"
pull_request:
branches: [main, master]
jobs:
release-pypackage:
runs-on: python311
env:
HATCH_INDEX_REPO: main
HATCH_INDEX_USER: __token__
HATCH_INDEX_AUTH: ${{ secrets.PYPI_TOKEN }}
steps:
- name: checkout code
uses: actions/checkout@v3
- name: setup go
uses: actions/setup-go@v4
with:
go-version: '>=1.20'
- name: install hatch
run: pip install -U hatch hatchling
- name: build package
run: hatch build --clean
- name: get release notes
id: release-notes
uses: olofvndrhr/releasenote-gen@v1
- name: create gitea release
uses: https://gitea.com/actions/release-action@main
if: gitea.event_name != 'pull_request'
with:
title: ${{ gitea.ref_name }}
body: ${{ steps.release-notes.outputs.releasenotes }}
files: |-
dist/**
- name: create github release
uses: ncipollo/release-action@v1
if: gitea.event_name != 'pull_request'
with:
token: ${{ secrets.GH_TOKEN }}
owner: olofvndrhr
repo: manga-dlp
name: ${{ gitea.ref_name }}
body: ${{ steps.release-notes.outputs.releasenotes }}
artifacts: |-
dist/**

View file

@ -1,18 +0,0 @@
name: run scheduled tests
on:
schedule:
- cron: "0 20 * * 6"
jobs:
check-code-py311:
runs-on: python311
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: run tests
run: hatch run default:test

2
.gitignore vendored
View file

@ -12,8 +12,6 @@ chaps.txt
mangas.txt mangas.txt
.idea/ .idea/
venv venv
test.sh
.ruff_cache/
### Python template ### Python template
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files

View file

@ -1,4 +1,5 @@
shellcheck 0.10.0 python 3.9.13 3.10.5 3.8.13 3.7.13 3.6.15
shfmt 3.8.0 shfmt 3.5.1
just 1.25.2 shellcheck 0.8.0
lefthook 1.4.6 just 1.2.0
direnv 2.32.1

View file

@ -0,0 +1,71 @@
###################################
# build and publish docker images #
###################################
# branch: master
# event: tag
depends_on:
- tests
clone:
git:
when:
#branch: master
event: tag
image: woodpeckerci/plugin-git
pipeline:
# build and publish docker image for amd64 - x86
build-amd64:
when:
#branch: master
event: tag
image: woodpeckerci/plugin-docker-buildx
pull: true
settings:
repo: olofvndrhr/manga-dlp
platforms: linux/amd64
dockerfile: docker/Dockerfile.amd64
auto_tag: true
auto_tag_suffix: linux-amd64
build_args: BUILD_VERSION=${CI_COMMIT_TAG}
username:
from_secret: cr-dhub-username
password:
from_secret: cr-dhub-key
# build and publish docker image for arm64
build-arm64:
when:
#branch: master
event: tag
image: woodpeckerci/plugin-docker-buildx
pull: true
settings:
repo: olofvndrhr/manga-dlp
platforms: linux/arm64
dockerfile: docker/Dockerfile.arm64
auto_tag: true
auto_tag_suffix: linux-arm64
build_args: BUILD_VERSION=${CI_COMMIT_TAG}
username:
from_secret: cr-dhub-username
password:
from_secret: cr-dhub-key
# publish docker manifest for automatic multi arch pulls
publish-manifest:
when:
#branch: master
event: tag
image: plugins/manifest
pull: true
settings:
spec: docker/manifest.tmpl
auto_tag: true
ignore_missing: true
username:
from_secret: cr-dhub-username
password:
from_secret: cr-dhub-key

View file

@ -0,0 +1,81 @@
###################
# publish release #
###################
# branch: master
# event: tag
depends_on:
- tests
clone:
git:
when:
#branch: master
event: tag
image: woodpeckerci/plugin-git
pipeline:
# build wheel and dist
build-pypi:
when:
#branch: master
event: tag
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- hatch build
# create release-notes
create-release-notes:
when:
#branch: master
event: tag
image: cr.44net.ch/baseimages/debian-base
pull: true
commands:
- bash get_release_notes.sh ${CI_COMMIT_TAG}
# publish release on gitea (git.44net.ch/olofvndrhr/manga-dlp)
publish-release-gitea:
when:
#branch: master
event: tag
image: plugins/gitea-release
pull: true
settings:
api_key:
from_secret: gitea-olofvndrhr-token
base_url: https://git.44net.ch
files: dist/*
title: ${CI_COMMIT_TAG}
note: RELEASENOTES.md
# publish release on github (github.com/olofvndrhr/manga-dlp)
publish-release-github:
when:
#branch: master
event: tag
image: woodpeckerci/plugin-github-release
pull: true
settings:
api_key:
from_secret: github-olofvndrhr-token
files: dist/*
title: ${CI_COMMIT_TAG}
note: RELEASENOTES.md
# release pypi
release-pypi:
when:
#branch: master
event: tag
image: cr.44net.ch/ci-plugins/tests
pull: true
secrets:
- source: pypi_username
target: HATCH_PYPI_USER
- source: pypi_token
target: HATCH_PYPI_AUTH
commands:
- hatch publish

View file

@ -0,0 +1,49 @@
############################
# test build docker images #
############################
# branch: master
# event: pull_request
depends_on:
- tests
clone:
git:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-git
pipeline:
# build docker image for amd64 - x86
test-build-amd64:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-docker-buildx
pull: true
settings:
dry_run: true
repo: olofvndrhr/manga-dlp
platforms: linux/amd64
dockerfile: docker/Dockerfile.amd64
auto_tag: true
auto_tag_suffix: linux-amd64-test
build_args: BUILD_VERSION=2.1.10
# build docker image for arm64
test-build-arm64:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-docker-buildx
pull: true
settings:
dry_run: true
repo: olofvndrhr/manga-dlp
platforms: linux/arm64
dockerfile: docker/Dockerfile.arm64
auto_tag: true
auto_tag_suffix: linux-arm64-test
build_args: BUILD_VERSION=2.1.10

View file

@ -0,0 +1,38 @@
################
# test release #
################
# branch: master
# event: pull_request
depends_on:
- tests
clone:
git:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-git
pipeline:
# build wheel and dist
test-build-pypi:
when:
branch: master
event: pull_request
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- hatch build
# create release-notes
test-create-release-notes:
when:
branch: master
event: pull_request
image: cr.44net.ch/baseimages/debian-base
pull: true
commands:
- bash get_release_notes.sh 2.1.10
- cat RELEASENOTES.md

View file

@ -0,0 +1,27 @@
##################
# test tox amd64 #
##################
# branch: master
# event: pull_request
depends_on:
- tests
clone:
git:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-git
pipeline:
# test code with different python versions - amd64
test-tox-amd64:
when:
branch: master
event: pull_request
image: cr.44net.ch/ci-plugins/multipy:1-linux-amd64
pull: true
commands:
- tox

View file

@ -0,0 +1,30 @@
##################
# test tox arm64 #
##################
# branch: master
# event: pull_request
depends_on:
- tests
clone:
git:
when:
branch: master
event: pull_request
image: woodpeckerci/plugin-git
pipeline:
# test code with different python versions - arm64
test-tox-arm64:
when:
branch: master
event: pull_request
image: cr.44net.ch/ci-plugins/multipy:1-linux-arm64
pull: true
commands:
- grep -v img2pdf contrib/requirements_dev.txt > contrib/requirements_dev_arm64.txt
- rm -f contrib/requirements_dev.txt
- mv contrib/requirements_dev_arm64.txt contrib/requirements_dev.txt
- tox

71
.woodpecker/tests.yml Normal file
View file

@ -0,0 +1,71 @@
##############################
# code testing and analysis #
#############################
# branch: all
# event: all
clone:
git:
image: woodpeckerci/plugin-git
pipeline:
# check code style - shell
test-shfmt:
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- shfmt -d -i 4 -bn -ci -sr .
# check code style - python
test-black:
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- black --check --diff .
# check imports - python
test-isort:
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- isort --check-only --diff .
# check static typing - python
test-mypy:
image: cr.44net.ch/ci-plugins/tests
pull: true
commands:
- mypy --install-types --non-interactive mangadlp/
# test code with different python versions
test-tox-pytest:
when:
event: [ push ]
image: cr.44net.ch/ci-plugins/multipy:1-linux-amd64
pull: true
commands:
- tox -e basic
# generate coverage report
test-tox-coverage:
when:
branch: master
event: [ pull_request ]
image: cr.44net.ch/ci-plugins/multipy:1-linux-amd64
pull: true
commands:
- tox -e coverage
# analyse code with sonarqube and upload it
sonarqube-analysis:
when:
branch: master
event: [ pull_request ]
image: cr.44net.ch/ci-plugins/sonar-scanner
pull: true
settings:
sonar_host: https://sonarqube.44net.ch
sonar_token:
from_secret: sq-44net-token
usingProperties: true

View file

@ -9,215 +9,6 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Add support for more sites - Add support for more sites
## [2.4.1] - 2024-02-01
- same as 2.4.0
## [2.4.0] - 2024-02-01
### Fixed
- Some issues with Python3.8 compatibility
### Changed
- Moved build system from woodpecker-ci to gitea actions
- Updated some dependencies
- Updated the docker image
- Switched from formatter/linter `black` to `ruff`
- Switches typing from `pyright` to `mypy`
## [2.3.1] - 2023-03-12
### Added
- Added TypedDicts for type checkers and type annotation
### Fixed
- Fixed some typos in the README
### Changed
- Switched from pylint/pylama/isort/autoflake to ruff
- Switched from mypy to pyright and added strict type checking
- Updated the api template
## [2.3.0] - 2023-02-15
### Added
- Metadata is now added to each chapter. Schema
standard: [https://anansi-project.github.io/docs/comicinfo/schemas/v2.0](https://anansi-project.github.io/docs/comicinfo/schemas/v2.0)
- Added `xmltodict` as a package requirement
- Cache now also saves the manga title
- New tests
- More typo annotations for function, compatible with python3.8
- File format checker if you use the MangaDLP class directly
### Fixed
- API template typos
- Some useless type annotations
### Changed
- Simplified the chapter info generation
- Updated the license year
- Updated the API template
- Updated the API detection and removed it from the MangaDLP class
## [2.2.20] - 2023-02-12
### Fixed
- Script now doesn't exit if multiple mangas were requested and one had an error
## [2.2.19] - 2023-02-11
### Added
- First version of the chapter cache (very basic functionality)
### Fixed
- Fixed all exception re-raises to include the original stack trace
### Changed
- Simplified chapter download loop
## [2.2.18] - 2023-01-21
### Fixed
- Fixed manga titles on non english language
- Fixed title & filename fixing to not use `ascii` but `uft8`
### Added
- Fallback title to english of none was found in requested language
- More debug logs
- More tests
### Changed
- Now uses the first found alt-title. Before it was the last
- Removed `sys.exit` in the api
## [2.2.17] - 2023-01-15
### Fixed
- Set a timeout of 10 seconds for the api requests
### Added
- `--name-format` and `--name-format-none` flags to add a custom naming scheme for the downloaded files. See
docs: https://manga-dlp.ivn.sh/download/
- More debug log messages
- More tests for the custom naming scheme
- More type hints
### Changed
- Make `--format` a `click.Choice` option
- In the `--format` option the leading dot is now invalid. `--format .cbz` -> `--format cbz`
- Changed empty values from the api from None to an empty string
- Minor code readability improvements
## [2.2.16] - 2022-12-30
### Fixed
- Log level is now fixed and should not default to 0
- Docker schedule should now work again
### Changed
- Integrate logging logs to loguru via custom sink
- Simplify docker shell scripts
## [2.2.15] - 2022-12-29
### Added
- `--warn` and `--loglevel` flags
### Removed
- Remove `--lean` and `--verbose` flags and remove custom log levels
### Changed
- Move from standard library logging to [loguru](https://loguru.readthedocs.io/en/stable/index.html)
- Move from standard library argparse to [click](https://click.palletsprojects.com/en/8.1.x/)
## [2.2.14] - 2022-10-06
### Changed
- Changed logging format to ISO 8601
- Small logging corrections
## [2.2.13] - 2022-08-15
### Added
- Option to run custom hooks before and after each chapter/manga download
- _Tests for the new hooks_
- _Docs for the new hooks_
- _Tests for mkdocs generation_
### Changed
- Verbose and Debug logging now have a space as a seperator between log level-name and log-level
- APIs now have an attribute with their name (for the hooks) - `api.api_name`
- Docs moved to Cloudflare pages (generated with mkdocs)
## [2.1.12] - 2022-07-25
### Fixed
- Image publishing with `hatch` on pypi should now work again
- The schedule fixer for the new `.sh` schedule should now work correctly
### Added
- More CI tests: `pylint`, `pylama` and `autoflake`
- New function in `get_release_notes.sh` to get the latest version
- Docstrings for `MangaDLP` and the api module `Mangadex`
### Changed
- CI workflow is now faster and runs natively on arm64 (before it was buildx/emulation)
- `Pylint`/`pylama` code improvements
- Version management is now done with `hatch` (in `__about__.py`)
## [2.1.11] - 2022-07-18
### Fixed
- The `--read` option now filters empty lines, so it will not generate an error anymore
- An error which was caused by the interactive input method when you did not specify a chapter or to list them
- Some typos
### Added
- Options to configure the default schedule in the docker container via environment variables
- Section the the docker [README.md](docker/README.md) for the new environment variables
- `autoflake` test in `justfile`
- Some more things which get logged
### Changed
- **BREAKING**: renamed the default schedule from `daily` to `daily.sh`. Don't forget to fix your bind-mounts to
overwrite
the default schedule
- Added the `.sh` suffix to the s6 init scripts for better compatibility
- Adjusted the new logging implementation. It shows now more info about the module the log is from, and some other
improvements
## [2.1.10] - 2022-07-14 ## [2.1.10] - 2022-07-14
### Fixed ### Fixed

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2021-present Ivan Schaller <ivan@schaller.sh> Copyright (c) 2022 Ivan Schaller
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -1 +1,10 @@
graft src include *.json
include *.md
include *.properties
include *.py
include *.txt
include *.yml
recursive-include contrib *.py
recursive-include mangadlp *.py
recursive-include tests *.py
recursive-include tests *.txt

217
README.md
View file

@ -1,55 +1,47 @@
# manga-dlp - python script to download mangas # manga-dlp
> Full docs: https://manga-dlp.ivn.sh ## python script to download mangas
Code Analysis #### CI/CD:
[![status-badge](https://img.shields.io/drone/build/olofvndrhr/manga-dlp?label=tests&server=https%3A%2F%2Fci.44net.ch)](https://ci.44net.ch/olofvndrhr/manga-dlp)
[![Last Release](https://img.shields.io/github/release-date/olofvndrhr/manga-DLP?label=last%20release)](https://github.com/olofvndrhr/manga-dlp/releases)
[![Version](https://img.shields.io/github/v/release/olofvndrhr/manga-dlp?label=version&sort=semver)](https://github.com/olofvndrhr/manga-dlp/releases)
#### Code Analysis:
[![Quality Gate Status](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=alert_status&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Coverage](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=coverage&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp) [![Coverage](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=coverage&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Bugs](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=bugs&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp) [![Bugs](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=bugs&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Maintainability Rating](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=sqale_rating&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp) [![Security](https://img.shields.io/snyk/vulnerabilities/github/olofvndrhr/manga-dlp)](https://app.snyk.io/org/olofvndrhr-t6h/project/aae9609d-a4e4-41f8-b1ac-f2561b2ad4e3)
[![Reliability Rating](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=reliability_rating&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Security Rating](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=security_rating&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
Meta #### Meta:
[![Formatter](https://img.shields.io/badge/code%20style-ruff-black)](https://github.com/charliermarsh/ruff)
[![Linter](https://img.shields.io/badge/linter-ruff-red)](https://github.com/charliermarsh/ruff)
[![Types](https://img.shields.io/badge/types-mypy-blue)](https://github.com/python/mypy)
[![Tests](https://img.shields.io/badge/tests-pytest%20%7C%20tox-yellow)](https://github.com/pytest-dev/pytest/)
[![Coverage](https://img.shields.io/badge/coverage-coveragepy-green)](https://github.com/nedbat/coveragepy)
[![License](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://snyk.io/learn/what-is-mit-license/)
[![Compatibility](https://img.shields.io/badge/python-3.11-blue)]()
[![Code style: black](https://img.shields.io/badge/code%20style-black-black)](https://github.com/psf/black)
[![Types - Mypy](https://img.shields.io/badge/types-mypy-blue)](https://github.com/python/mypy)
[![Imports - isort](https://img.shields.io/badge/imports-isort-ef8336.svg)](https://github.com/pycqa/isort)
[![Tests](https://img.shields.io/badge/tests-pytest-yellow)](https://github.com/pytest-dev/pytest/)
[![Coverage generation](https://img.shields.io/badge/coverage-coveragepy-green)](https://github.com/nedbat/coveragepy)
[![License - MIT](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://snyk.io/learn/what-is-mit-license/)
[![Compatibility](https://img.shields.io/pypi/pyversions/manga-dlp)](https://pypi.org/project/manga-dlp/)
--- ---
## Description ## Description
A manga download script written in python. It only supports [mangadex.org](https://mangadex.org/) for now. But support A manga download script written in python. It only supports [mangadex.org](https://mangadex.org/) for now. But support
for other sites is _planned™_. for other sites is planned.
Before downloading a new chapter, the script always checks if there is already a chapter with the same name in the Before downloading a new chapter, the script always checks if there is already a chapter with the same name in the
download directory. If found the chapter is skipped. So you can run the script on a schedule to only download new download directory. If found the chapter is skipped. So you can run the script on a schedule to only download new
chapters without any additional setup. chapters without any additional setup.
The default behaiviour is to pack the images to a [cbz archive](https://en.wikipedia.org/wiki/Comic_book_archive). If The default behaiviour is to pack the images to a [cbz archive](https://en.wikipedia.org/wiki/Comic_book_archive). If
you just want the folder with all the pictures use the flag `--format ""`. you just want the folder with all the pictures use the flag `--nocbz`.
## _Currently_ Supported sites ## _Currently_ Supported sites
- [Mangadex.org](https://mangadex.org/) - [Mangadex.org](https://mangadex.org/)
## Features (not complete)
- Metadata support with [ComicInfo.xml](https://anansi-project.github.io/docs/comicinfo/intro)
- Json caching
- Custom hooks after/before each download
- Custom chapter name format
- Volume support
- Multiple archive formats supported (cbz,cbr,zip,none)
- Language selection
- Download all chapters directly
- And others...
## Usage ## Usage
### Quick start ### Quick start
@ -90,58 +82,161 @@ mangadlp <args> # call script directly
### With docker ### With docker
See the docker [README](https://manga-dlp.ivn.sh/docker/) See the docker [README](./docker/README.md)
## Options ## Options
```txt ```txt
Usage: manga-dlp.py [OPTIONS] usage: manga-dlp.py [-h] (-u URL_UUID | --read READ | -v) [-c CHAPTERS] [-p PATH] [-l LANG] [--list] [--format FORMAT] [--forcevol] [--wait WAIT] [--verbose]
Script to download mangas from various sites Script to download mangas from various sites
Options: options:
--help Show this message and exit. -h, --help show this help message and exit
--version Show the version and exit. -u URL_UUID, --url URL_UUID URL or UUID of the manga
source: [mutually_exclusive, required] --read READ Path of file with manga links to download. One per line
-u, --url, --uuid TEXT URL or UUID of the manga -v, --version Show version of manga-dlp and exit
--read FILE Path of file with manga links to download. One per line -c CHAPTERS, --chapters CHAPTERS Chapters to download
verbosity: [mutually_exclusive] -p PATH, --path PATH Download path. Defaults to "<script_dir>/downloads"
--loglevel INTEGER Custom log level -l LANG, --language LANG Manga language. Defaults to "en" --> english
--warn Only log warnings and higher --list List all available chapters. Defaults to false
--debug Debug logging. Log EVERYTHING --format FORMAT Archive format to create. An empty string means dont archive the folder. Defaults to 'cbz'
-c, --chapters TEXT Chapters to download --forcevol Force naming of volumes. For mangas where chapters reset each volume
-p, --path PATH Download path [default: downloads] --wait WAIT Time to wait for each picture to download in seconds(float). Defaults 0.5
-l, --language TEXT Manga language [default: en] --lean Lean logging. Minimal log output. Defaults to false
--list List all available chapters --verbose Verbose logging. More log output. Defaults to false
--format [cbz|cbr|zip|pdf|] Archive format to create. An empty string means don't archive the folder [default: cbz] --debug Debug logging. Most log output. Defaults to false
--name-format TEXT Naming format to use when saving chapters. See docs for more infos [default: {default}]
--name-format-none TEXT String to use when the variable of the custom name format is empty
--forcevol Force naming of volumes. For mangas where chapters reset each volume
--wait FLOAT Time to wait for each picture to download in seconds(float) [default: 0.5]
--hook-manga-pre TEXT Commands to execute before the manga download starts
--hook-manga-post TEXT Commands to execute after the manga download finished
--hook-chapter-pre TEXT Commands to execute before the chapter download starts
--hook-chapter-post TEXT Commands to execute after the chapter download finished
--cache-path PATH Where to store the cache-db. If no path is given, cache is disabled
--add-metadata / --no-metadata Enable/disable creation of metadata via ComicInfo.xml [default: add-metadata]
``` ```
### Downloads file-structure
```txt
.
└── <download path>/
└── <manga title>/
└── <chapter title>/
```
#### Example:
```txt
./downloads/mangatitle/chaptertitle(.cbz)
```
### Select chapters to download
> With the option `-c "all"` you download every chapter available in the selected language
To download specific chapters you can use the option `-c` or `--chapters`. That you don't have to specify all chapters
individually, the script has some logic to fill in the blanks.
Examples:
```sh
# if you want to download chapters 1 to 5
python3 manga-dlp -u <url> -c 1-5
# if you want to download chapters 1 and 5
python3 manga-dlp -u <url> -c 1,5
```
If you use `--forcevol` it's the same, just with the volume number
```sh
# if you want to download chapters 1:1 to 1:5
python3 manga-dlp -u <url> -c 1:1-1:5
# if you want to download chapters 1:1 and 1:5
python3 manga-dlp -u <url> -c 1:1,1:5
# to download the whole volume 1
python3 manga-dlp -u <url> -c 1:
```
And a combination of all
```sh
# if you want to download chapters 1 to 5 and 9
python3 manga-dlp -u <url> -c 1-5,9
# with --forcevol
# if you want to download chapters 1:1 to 1:5 and 9, also the whole volume 4
python3 manga-dlp -u <url> -c 1:1-1:5,1:9,4:
```
### Read list of links from file
With the option `--read` you can specify a file with links to multiple mangas. They will be parsed from top to bottom
one at a time. Every link will be matched for the right api to use. It is important that you only have one link per
line, otherwise they can't be parsed.
#### Example:
```txt
# mangas.txt
link1
link2
link3
```
`python3 manga-dlp.py --read mangas.txt --list`
This will list all available chapters for link1, link2 and link3.
### Set download path
With the option `-p/--path` you can specify a path to download the chapters to. The default path
is `<script_dir>/downloads`. Absolute and relative paths are supported.
#### Example:
`python3 manga-dlp.py <other options> --path /media/mangas`
This will save all mangas/chapters in the path `/media/mangas/<manga title>/<chapter name>`
### Set output format
> `--format` currently only works with `""`, `"pdf"`, `"zip"`, `"rar"` and `"cbz"`.
> As it just renames the zip file with the new
> suffix (except pdf).
You can specify the output format of the manga images with the `--format` option.
The default is set to `.cbz`, so if no format is given it falls back to `<manga-name>/<chapter_name>.cbz`
For pdf creation you have to install [img2pdf](https://pypi.org/project/img2pdf/).
With the amd64 docker image it is already installed
see more in the Docker [README.md](docker/README.md).
#### Supported format options are:
* cbz - `--format "cbz"` or `--format ".cbz"` **- default**
* cbr - `--format "cbr"` or `--format ".cbr"`
* zip - `--format "zip"` or `--format ".zip"`
* pdf - `--format "pdf"` or `--format ".pdf"`
* _none_ - `--format ""` - this saves the images just in a folder
#### Example:
`python3 manga-dlp.py <other options> --format "zip"`
This will download the chapter and save it as a zip archive.
## Contribution / Bugs ## Contribution / Bugs
For suggestions for improvement, just open a pull request. For suggestions for improvement, just open a pull request.
If you want to add support for a new site, there is an api [template file](contrib/api_template.py) which you can use. If you want to add support for a new site, there is an api [template file](./contrib/api_template.py) which you can use.
And more infos and tools are in the contrib [README.md](contrib/README.md) And more infos and tools in the contrib [README.md](contrib/README.md)
Otherwise, you can open an issue with the name of the site which you want support for (not guaranteed to be Otherwise, you can open am issue with the name of the site which you want support for. (not guaranteed to be
implemented). implemented)
If you encounter any bugs, also just open an issue with a description of the problem. If you encounter any bugs, also just open an issue with a description of the problem.
## TODO's ## TODO's
- <del>Make docker container for easy distribution</del> - <del>Make docker container for easy distribution</del>
--> [Dockerhub](https://hub.docker.com/r/olofvndrhr/manga-dlp) --> [Dockerhub](https://hub.docker.com/repository/docker/olofvndrhr/manga-dlp)
- <del>Automate release</del> - <del>Automate release</del>
--> Done with woodpecker-ci --> Done with woodpecker-ci
- <del>Make pypi package</del> - <del>Make pypi package</del>

View file

@ -1,37 +1,14 @@
from typing import Dict, List
from mangadlp.models import ChapterData, ComicInfo
# api template for manga-dlp # api template for manga-dlp
class YourAPI: class YourAPI:
"""Your API Class.
Get infos for a manga from example.org.
Args:
url_uuid (str): URL or UUID of the manga
language (str): Manga language with country codes. "en" --> english
forcevol (bool): Force naming of volumes. Useful for mangas where chapters reset each volume
Attributes:
api_name (str): Name of the API
manga_uuid (str): UUID of the manga, without the url part
manga_title (str): The title of the manga, sanitized for all filesystems
chapter_list (list): A list of all available chapters for the language
"""
# api information - example # api information - example
api_base_url = "https://api.mangadex.org" api_base_url = "https://api.mangadex.org"
img_base_url = "https://uploads.mangadex.org" img_base_url = "https://uploads.mangadex.org"
def __init__(self, url_uuid: str, language: str, forcevol: bool): # get infos to initiate class
"""get infos to initiate class.""" def __init__(self, url_uuid, language, forcevol):
self.api_name = "Your API Name" # static info
self.url_uuid = url_uuid self.url_uuid = url_uuid
self.language = language self.language = language
self.forcevol = forcevol self.forcevol = forcevol
@ -39,126 +16,24 @@ class YourAPI:
# attributes needed by app.py # attributes needed by app.py
self.manga_uuid = "abc" self.manga_uuid = "abc"
self.manga_title = "abc" self.manga_title = "abc"
self.chapter_list = ["1", "2", "2.1", "5", "10"] self.chapter_list = "abc"
self.manga_chapter_data: Dict[str, ChapterData] = { # example data
"1": {
"uuid": "abc",
"volume": "1",
"chapter": "1",
"name": "test",
"pages": 2,
},
"2": {
"uuid": "abc",
"volume": "1",
"chapter": "2",
"name": "test",
"pages": 45,
},
}
# or with --forcevol
self.manga_chapter_data: Dict[str, ChapterData] = {
"1:1": {
"uuid": "abc",
"volume": "1",
"chapter": "1",
"name": "test",
},
"1:2": {
"uuid": "abc",
"volume": "1",
"chapter": "2",
"name": "test",
},
}
def get_chapter_images(self, chapter: str, wait_time: float) -> List[str]: # methods needed by app.py
"""Get chapter images as a list (full links). # get chapter infos as a dictionary
def get_chapter_infos(chapter: str) -> dict:
# these keys have to be returned
return {
"uuid": chapter_uuid,
"volume": chapter_vol,
"chapter": chapter_num,
"name": chapter_name,
}
Args: # get chapter images as a list (full links)
chapter: The chapter number (chapter data index) def get_chapter_images(chapter: str, download_wait: float) -> list:
download_wait: Wait time between image downloads
Returns:
The list of urls of the page images
"""
# example # example
return [ return [
"https://abc.def/image/123.png", "https://abc.def/image/123.png",
"https://abc.def/image/1234.png", "https://abc.def/image/1234.png",
"https://abc.def/image/12345.png", "https://abc.def/image/12345.png",
] ]
def create_metadata(self, chapter: str) -> ComicInfo:
"""Get metadata with correct keys for ComicInfo.xml.
Provide as much metadata as possible. empty/false values will be ignored.
Args:
chapter: The chapter number (chapter data index)
Returns:
The metadata as a dict
"""
# metadata types. have to be valid
# {key: (type, default value, valid values)}
{
"Title": (str, None, []),
"Series": (str, None, []),
"Number": (str, None, []),
"Count": (int, None, []),
"Volume": (int, None, []),
"AlternateSeries": (str, None, []),
"AlternateNumber": (str, None, []),
"AlternateCount": (int, None, []),
"Summary": (str, None, []),
"Notes": (
str,
"Downloaded with https://github.com/olofvndrhr/manga-dlp",
[],
),
"Year": (int, None, []),
"Month": (int, None, []),
"Day": (int, None, []),
"Writer": (str, None, []),
"Colorist": (str, None, []),
"Publisher": (str, None, []),
"Genre": (str, None, []),
"Web": (str, None, []),
"PageCount": (int, None, []),
"LanguageISO": (str, None, []),
"Format": (str, None, []),
"BlackAndWhite": (str, None, ["Yes", "No", "Unknown"]),
"Manga": (str, "Yes", ["Yes", "No", "Unknown", "YesAndRightToLeft"]),
"ScanInformation": (str, None, []),
"SeriesGroup": (str, None, []),
"AgeRating": (
str,
None,
[
"Unknown",
"Adults Only 18+",
"Early Childhood",
"Everyone",
"Everyone 10+",
"G",
"Kids to Adults",
"M",
"MA15+",
"Mature 17+",
"PG",
"R18+",
"Rating Pending",
"Teen",
"X18+",
],
),
"CommunityRating": (int, None, [1, 2, 3, 4, 5]),
}
# example
return {
"Volume": 1,
"LanguageISO": "en",
"Title": "test",
}

View file

@ -1,20 +1,13 @@
# application requirements # application requirements
requests>=2.28.0 requests>=2.24.0
loguru>=0.6.0
click>=8.1.3
click-option-group>=0.5.5
xmltodict>=0.13.0
xmlschema>=2.2.1
img2pdf>=0.4.4 img2pdf>=0.4.4
# dev and testing requirements # dev and testing requirements
hatch>=1.6.0
hatchling>=1.11.0
pytest>=7.0.0 pytest>=7.0.0
coverage>=6.3.1 coverage>=6.3.1
black>=22.1.0 black>=22.1.0
isort>=5.10.0
pylint>=2.13.0
mypy>=0.940 mypy>=0.940
tox>=3.24.5 tox>=3.24.5
ruff>=0.0.247 hatch>=1.0.0
pyright>=1.1.294

View file

@ -1,39 +0,0 @@
FROM git.44net.ch/44net/python311:11 AS builder
COPY pyproject.toml README.md /build/
COPY src /build/src
WORKDIR /build
RUN \
echo "**** building package ****" \
&& pip3 install hatch hatchling \
&& python3 -m hatch build --clean
FROM git.44net.ch/44net/debian-s6:11
LABEL maintainer="Ivan Schaller" \
description="A CLI manga downloader"
ENV PATH="/opt/python3/bin:${PATH}"
COPY --from=builder /opt/python3 /opt/python3
COPY --from=builder /build/dist/*.whl /build/dist/
COPY docker/rootfs /
RUN \
echo "**** creating folders ****" \
&& mkdir -p /app \
&& echo "**** updating pip ****" \
&& python3 -m pip install --upgrade pip setuptools wheel \
&& echo "**** install python packages ****" \
&& python3 -m pip install /build/dist/*.whl
RUN \
echo "**** cleanup ****" \
&& apt-get purge --auto-remove -y \
&& apt-get clean \
&& rm -rf \
/tmp/* \
/var/lib/apt/lists/* \
/var/tmp/*
WORKDIR /app

48
docker/Dockerfile.amd64 Normal file
View file

@ -0,0 +1,48 @@
FROM cr.44net.ch/baseimages/debian-s6:1.3.6-linux-amd64
# set version label
ARG BUILD_VERSION
ENV MDLP_VERSION=${BUILD_VERSION}
LABEL version="${BUILD_VERSION}"
LABEL maintainer="Ivan Schaller"
LABEL description="A CLI manga downloader"
# install packages
RUN \
echo "**** install base packages ****" \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
python3-pip
# prepare app
RUN \
echo "**** creating folders ****" \
&& mkdir -p /app
# cleanup installation
RUN \
echo "**** cleanup ****" \
&& apt-get purge --auto-remove -y \
&& apt-get clean \
&& rm -rf \
/tmp/* \
/var/lib/apt/lists/* \
/var/tmp/*
# copy files to container
COPY docker/rootfs /
COPY mangadlp/ /app/mangadlp/
COPY \
manga-dlp.py \
requirements.txt \
LICENSE \
/app/
# install requirements
RUN pip install -r /app/requirements.txt
WORKDIR /app

50
docker/Dockerfile.arm64 Normal file
View file

@ -0,0 +1,50 @@
FROM cr.44net.ch/baseimages/debian-s6:1.3.6-linux-arm64
# set version label
ARG BUILD_VERSION
ENV MDLP_VERSION=${BUILD_VERSION}
LABEL version="${BUILD_VERSION}"
LABEL maintainer="Ivan Schaller"
LABEL description="A CLI manga downloader"
# install packages
RUN \
echo "**** install base packages ****" \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
python3-pip
# prepare app
RUN \
echo "**** creating folders ****" \
&& mkdir -p /app
# cleanup installation
RUN \
echo "**** cleanup ****" \
&& apt-get purge --auto-remove -y \
&& apt-get clean \
&& rm -rf \
/tmp/* \
/var/lib/apt/lists/* \
/var/tmp/*
# copy files to container
COPY docker/rootfs /
COPY mangadlp/ /app/mangadlp/
COPY \
manga-dlp.py \
requirements.txt \
LICENSE \
/app/
# install requirements (without img2pdf)
RUN grep -v img2pdf /app/requirements.txt > /app/requirements-arm64.txt
RUN pip install -r /app/requirements-arm64.txt
WORKDIR /app

View file

@ -1,10 +1,8 @@
# Docker container of manga-dlp # Docker container of manga-dlp
> Full docs: https://manga-dlp.ivn.sh/docker
## Quick start ## Quick start
The pdf creation only works on amd64 images, as it unfortunately is incompatible with arm64. > the pdf creation only works on amd64 images, as it unfortunately is incompatible with arm64.
```sh ```sh
# with docker-compose # with docker-compose
@ -15,3 +13,115 @@ docker-compose up -d
# with docker run # with docker run
docker run -v ./downloads:/app/downloads -v ./mangas.txt:/app/mangas.txt olofvndrhr/manga-dlp docker run -v ./downloads:/app/downloads -v ./mangas.txt:/app/mangas.txt olofvndrhr/manga-dlp
``` ```
### Change UID/GID
> The default UID and GID are 4444.
You can change the UID and GID of the container user simply with:
```yml
# docker-compose.yml
environment:
- PUID=<userid>
- PGID=<groupid>
```
```sh
docker run -e PUID=<userid> -e PGID=<groupid>
```
## Run commands in container
> You don't need to use the full path of manga-dlp.py because `/app` already is the working directory
You can simply use the `docker exec` command to run the scripts like normal.
```sh
docker exec <container name> python3 manga-dlp.py <options>
```
## Run your own schedule
The default config runs `manga-dlp.py` once a day at 12:00 and fetches every chapter of the mangas listed in the file
`mangas.txt` in the root directory of this repo.
#### The default schedule:
```sh
#!/bin/bash
python3 /app/manga-dlp.py \
--path /app/downloads \
--read /app/mangas.txt \
--chapters all \
--wait 2 \
--lean
```
To use your own schedule you need to mount (override) the default schedule or add new ones to the crontab.
> Don't forget to add the cron entries for every new schedule
```yml
# docker-compose.yml
volumes:
- ./crontab:/etc/cron.d/mangadlp # overwrites the default crontab
- ./crontab2:/etc/cron.d/something # adds a new one crontab file
- ./schedule1:/app/schedules/daily # overwrites the default schedule
- ./schedule2:/app/schedules/weekly # adds a new schedule
```
```sh
docker run -v ./crontab:/etc/cron.d/mangadlp # overwrites the default crontab
docker run -v ./crontab2:/etc/cron.d/something # adds a new one crontab file
docker run -v ./schedule1:/app/schedules/daily # overwrites the default schedule
docker run -v ./schedule2:/app/schedules/weekly # adds a new schedule
```
#### The default crontab file:
```sh
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# default crontab to run manga-dlp once a day
# and get all (new) chapters of the mangas in
# the file mangas.txt
# "/proc/1/fd/1 2>&1" is to show the logs in the container
# "s6-setuidgid abc" is used to set the permissions
0 12 * * * root s6-setuidgid abc /app/schedules/daily > /proc/1/fd/1 2>&1
```
## Add mangas to mangas.txt
If you use the default crontab you still need to add some mangas to mangas.txt. This is done almost identical to adding
your own cron schedule. If you use a custom cron schedule you need to mount the file you specified with `--read`.
```yml
# docker-compose.yml
volumes:
- ./mangas.txt:/app/mangas.txt
```
```sh
docker run -v ./mangas.txt:/app/mangas.txt
```
## Change download directory
Per default as in the script, it downloads everything to "downloads" in the scripts root directory. This data does not
persist with container recreation, so you need to mount it. This is already done in the quick start section. If you want
to change the path of the host, simply change `./downloads/` to a path of your choice.
```yml
# docker-compose.yml
volumes:
- ./downloads/:/app/downloads
```
```sh
docker run -v ./downloads/:/app/downloads
```

View file

@ -13,13 +13,15 @@ services:
- ./downloads/:/app/downloads/ # default manga download directory - ./downloads/:/app/downloads/ # default manga download directory
- ./mangas.txt:/app/mangas.txt # default file for manga links to download - ./mangas.txt:/app/mangas.txt # default file for manga links to download
#- ./crontab:/etc/cron.d/mangadlp # path to default crontab #- ./crontab:/etc/cron.d/mangadlp # path to default crontab
#- ./schedule.sh:/app/schedules/daily.sh # path to the default schedule which is run daily #- ./schedule:/app/schedules/daily # path to the default schedule which is run daily
environment: environment:
- TZ=Europe/Zurich - TZ=Europe/Zurich
#- PUID= # custom user id - defaults to 4444 # - PUID= # custom userid - defaults to 4444
#- PGID= # custom group id - defaults to 4444 # - PGID= # custom groupid - defaults to 4444
networks: networks:
appnet: appnet:
name: mangadlp name: mangadlp
driver: bridge driver: bridge

20
docker/manifest.tmpl Normal file
View file

@ -0,0 +1,20 @@
image: olofvndrhr/manga-dlp:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}dev{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
- "latest"
{{/if}}
manifests:
-
image: olofvndrhr/manga-dlp:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{else}}dev-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
-
image: olofvndrhr/manga-dlp:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{else}}dev-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8

View file

@ -4,4 +4,5 @@ python3 /app/manga-dlp.py \
--path /app/downloads \ --path /app/downloads \
--read /app/mangas.txt \ --read /app/mangas.txt \
--chapters all \ --chapters all \
--wait 2 --wait 2 \
--lean

View file

@ -4,12 +4,8 @@
# set all env variables for further use. If variable is unset, it will have the defaults on the right side after ":=" # set all env variables for further use. If variable is unset, it will have the defaults on the right side after ":="
# custom env vars # custom env vars
: "${MDLP_GENERATE_SCHEDULE:=false}"
: "${MDLP_PATH:=/app/downloads}"
: "${MDLP_READ:=/app/mangas.txt}"
: "${MDLP_LANGUAGE:=en}" : "${MDLP_LANGUAGE:=en}"
: "${MDLP_CHAPTERS:=all}"
: "${MDLP_FILE_FORMAT:=cbz}"
: "${MDLP_WAIT:=0.5}"
: "${MDLP_FORCEVOL:=false}" : "${MDLP_FORCEVOL:=false}"
: "${MDLP_LOG_LEVEL:=}" : "${MDLP_FILE_FORMAT:=cbz}"
: "${MDLP_DOWNLOAD_WAIT:=2}"
: "${MDLP_VERBOSE:=false}"

View file

@ -1,53 +0,0 @@
#!/usr/bin/with-contenv bash
# shellcheck shell=bash
# source env variables
source /etc/cont-init.d/20-setenv.sh
custom_args=(
--path "${MDLP_PATH}"
--read "${MDLP_READ}"
--language "${MDLP_LANGUAGE}"
--chapters "${MDLP_CHAPTERS}"
--format "${MDLP_FILE_FORMAT}"
--wait "${MDLP_WAIT}"
)
function prepare_vars() {
# set log level
case "${MDLP_LOG_LEVEL}" in
"warn")
custom_args+=("--warn")
;;
"debug")
custom_args+=("--debug")
;;
*)
if [[ -n "${MDLP_LOG_LEVEL}" ]]; then
custom_args+=("--loglevel" "${MDLP_LOG_LEVEL}")
fi
;;
esac
# check if forcevol should be used
if [[ "${MDLP_FORCEVOL,,}" == "true" ]]; then
custom_args+=("--forcevol")
fi
}
# set schedule with env variables
function set_vars() {
cat << EOF > "/app/schedules/daily.sh"
#!/bin/bash
python3 /app/manga-dlp.py ${custom_args[@]}
EOF
}
# check if schedule should be generated
if [[ "${MDLP_GENERATE_SCHEDULE,,}" == "true" ]]; then
echo "Generating schedule"
prepare_vars
set_vars
fi

View file

@ -2,7 +2,7 @@
# shellcheck shell=bash # shellcheck shell=bash
# source env variables # source env variables
source /etc/cont-init.d/20-setenv.sh source /etc/cont-init.d/20-setenv
# fix permissions # fix permissions
find '/app' -type 'd' \( -not -perm 775 -and -not -path '/app/downloads*' \) -exec chmod 775 '{}' \+ find '/app' -type 'd' \( -not -perm 775 -and -not -path '/app/downloads*' \) -exec chmod 775 '{}' \+

View file

@ -7,4 +7,5 @@ PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# "/proc/1/fd/1 2>&1" is to show the logs in the container # "/proc/1/fd/1 2>&1" is to show the logs in the container
# "s6-setuidgid abc" is used to set the permissions # "s6-setuidgid abc" is used to set the permissions
0 12 * * * root s6-setuidgid abc /app/schedules/daily.sh > /proc/1/fd/1 2>&1 0 12 * * * root s6-setuidgid abc /app/schedules/daily > /proc/1/fd/1 2>&1

View file

@ -1,32 +0,0 @@
site_name: manga-dlp
site_url: https://manga-dlp.ivn.sh/
site_description: Documentation for manga-dlp
site_author: Ivan Schaller
copyright: MIT
repo_url: https://github.com/olofvndrhr/manga-dlp/
repo_name: manga-dlp
edit_uri: edit/master/docs/pages/
docs_dir: pages
theme:
name: readthedocs
locale: en
include_homepage_in_sidebar: true
prev_next_buttons_location: bottom
collapse_navigation: false
sticky_navigation: true
highlightjs: true
hljs_languages:
- yaml
- sh
- python
- txt
- md
nav:
- Home: index.md
- Download: download.md
- Hooks: hooks.md
- Docker: docker.md

View file

@ -1,146 +0,0 @@
# Docker container of manga-dlp
## Quick start
> the pdf creation only works on amd64 images, as it unfortunately is incompatible with arm64.
```sh
# with docker-compose
curl -O docker-compose.yml https://raw.githubusercontent.com/olofvndrhr/manga-dlp/master/docker/docker-compose.yml
# adjust settings to your needs
docker-compose up -d
# with docker run
docker run -v ./downloads:/app/downloads -v ./mangas.txt:/app/mangas.txt olofvndrhr/manga-dlp
```
## Change UID/GID
> The default UID and GID are 4444.
You can change the UID and GID of the container user simply with:
```yml
# docker-compose.yml
environment:
- PUID=<userid>
- PGID=<groupid>
```
```sh
docker run -e PUID=<userid> -e PGID=<groupid>
```
## Environment variables
You can configure the default schedule via environment variables. Don't forget to set `MDLP_GENERATE_SCHEDULE` to "true"
, else
it will not generate it (it will just use the default one).
For more info's about the options, you can look in the main scripts [README.md](../)
| ENV Variable | Default | manga-dlp option | Info |
|:-----------------------|:----------------|:------------------------------------|--------------------------------------------------------------------------|
| MDLP_GENERATE_SCHEDULE | false | none | Has to be set to "true" to generate the config via environment variables |
| MDLP_PATH | /app/downloads | --path | |
| MDLP_READ | /app/mangas.txt | --read | |
| MDLP_LANGUAGE | en | --language | |
| MDLP_CHAPTERS | all | --chapter | |
| MDLP_FILE_FORMAT | cbz | --format | |
| MDLP_WAIT | 0.5 | --wait | |
| MDLP_FORCEVOL | false | --forcevol | |
| MDLP_LOG_LEVEL | <none> | --warn / --debug / --loglevel <INT> | Can either be set to: warn, debug or a custom loglevel integer |
## Run commands in container
> You don't need to use the full path of manga-dlp.py because `/app` already is the working directory
You can simply use the `docker exec` command to run the scripts like normal.
```sh
docker exec <container name> python3 manga-dlp.py <options>
```
## Run your own schedule
The default config runs `manga-dlp.py` once a day at 12:00 and fetches every chapter of the mangas listed in the file
`mangas.txt` in the root directory of this repo.
#### Default schedule
```sh
#!/bin/bash
python3 /app/manga-dlp.py \
--path /app/downloads \
--read /app/mangas.txt \
--chapters all \
--wait 2 \
--warn
```
To use your own schedule you need to mount (override) the default schedule or add new ones to the crontab.
> Don't forget to add the cron entries for every new schedule
```yml
# docker-compose.yml
volumes:
- ./crontab:/etc/cron.d/mangadlp # overwrites the default crontab
- ./crontab2:/etc/cron.d/something # adds a new one crontab file
- ./schedule1.sh:/app/schedules/daily.sh # overwrites the default schedule
- ./schedule2.sh:/app/schedules/weekly.sh # adds a new schedule
```
```sh
docker run -v ./crontab:/etc/cron.d/mangadlp # overwrites the default crontab
docker run -v ./crontab2:/etc/cron.d/something # adds a new one crontab file
docker run -v ./schedule1.sh:/app/schedules/daily.sh # overwrites the default schedule
docker run -v ./schedule2.sh:/app/schedules/weekly.sh # adds a new schedule
```
#### Default crontab file
```sh
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# default crontab to run manga-dlp once a day
# and get all (new) chapters of the mangas in
# the file mangas.txt
# "/proc/1/fd/1 2>&1" is to show the logs in the container
# "s6-setuidgid abc" is used to set the permissions
0 12 * * * root s6-setuidgid abc /app/schedules/daily.sh > /proc/1/fd/1 2>&1
```
## Add mangas to mangas.txt
If you use the default crontab you still need to add some mangas to mangas.txt. This is done almost identical to adding
your own cron schedule. If you use a custom cron schedule you need to mount the file you specified with `--read`.
```yml
# docker-compose.yml
volumes:
- ./mangas.txt:/app/mangas.txt
```
```sh
docker run -v ./mangas.txt:/app/mangas.txt
```
## Change download directory
Per default as in the script, it downloads everything to "downloads" in the scripts root directory. This data does not
persist with container recreation, so you need to mount it. This is already done in the quick start section. If you want
to change the path of the host, simply change `./downloads/` to a path of your choice.
```yml
# docker-compose.yml
volumes:
- ./downloads/:/app/downloads
```
```sh
docker run -v ./downloads/:/app/downloads
```

View file

@ -1,182 +0,0 @@
# Download mangas
## File-structure
```txt
.
└── <download path>/
└── <manga title>/
└── <chapter title>/
└── ComicInfo.xml (optional)
└── 001.png
└── 002.png
└── etc.
```
**Example:**
```txt
./downloads/mangatitle/chaptertitle(.cbz)
```
## Select chapters to download
> With the option `-c "all"` you download every chapter available in the selected language
To download specific chapters you can use the option `-c` or `--chapters`. That you don't have to specify all chapters
individually, the script has some logic to fill in the blanks.
Examples:
```sh
# if you want to download chapters 1 to 5
python3 manga-dlp -u <url> -c 1-5
# if you want to download chapters 1 and 5
python3 manga-dlp -u <url> -c 1,5
```
If you use `--forcevol` it's the same, just with the volume number
```sh
# if you want to download chapters 1:1 to 1:5
python3 manga-dlp -u <url> -c 1:1-1:5
# if you want to download chapters 1:1 and 1:5
python3 manga-dlp -u <url> -c 1:1,1:5
# to download the whole volume 1
python3 manga-dlp -u <url> -c 1:
```
And a combination of all
```sh
# if you want to download chapters 1 to 5 and 9
python3 manga-dlp -u <url> -c 1-5,9
# with --forcevol
# if you want to download chapters 1:1 to 1:5 and 9, also the whole volume 4
python3 manga-dlp -u <url> -c 1:1-1:5,1:9,4:
```
## Set download path
With the option `-p/--path` you can specify a path to download the chapters to. The default path
is `<script_dir>/downloads`. Absolute and relative paths are supported.
**Example:**
`python3 manga-dlp.py <other options> --path /media/mangas`
This will save all mangas/chapters in the path `/media/mangas/<manga title>/<chapter name>`
## Set output format
> `--format` currently only works with `""`, `"pdf"`, `"zip"`, `"cbr"` and `"cbz"`.
> As it just renames the zip file with the new
> suffix (except pdf).
You can specify the output format of the manga images with the `--format` option.
The default is set to `.cbz`, so if no format is given it falls back to `<manga-name>/<chapter_name>.cbz`
For pdf creation you have to install [img2pdf](https://pypi.org/project/img2pdf/).
With the amd64 docker image it is already installed
see more in the Docker [README.md](../docker/).
**Supported format options:**
* cbz -> `--format "cbz"` **- default**
* cbr -> `--format "cbr"`
* zip -> `--format "zip"`
* pdf -> `--format "pdf"`
* _none_ -> `--format ""` - this saves the images just in a folder
**Example:**
`python3 manga-dlp.py <other options> --format "zip"`
This will download the chapter and save it as a zip archive.
## Set chapter naming format
You can specify the naming format of the downloaded chapters with the `--name-format` option.
Just be sure that you use quotation marks so that the cli parser interprets it as one string.
Available placeholders are:
- `{manga_title}` -> The name of the manga
- `{chapter_name}` -> The name of the chapter
- `{chapter_vol}` -> The volume number of the chapter
- `{chapter_num}` -> The chapter number
**Example:**
- Manga title: "Test title"
- Chapter name: "Test chapter"
- Chapter volume: 3
- Chapter number: 2
`python3 manga-dlp.py <other options> --format "cbz" --name-format "{chapter_name}-{chapter_vol}-{chapter_num}"`
This will create an archive with the name: `Test chapter-3-2.cbz`
You don't have to use all variables, but if you use an invalid placeholder, it will fall back to the default naming.
### Set empty variables
If the placeholder variables are empty, the default behaviour is to set it as an empty string. But this can be changed
with the `--name-format-none` flag.
**Example:**
- Manga title: "Test title"
- Chapter name: "Test chapter"
- Chapter volume:
- Chapter number: 2
`python3 manga-dlp.py <other options> --format "cbz" --name-format "{chapter_name}-{chapter_vol}-{chapter_num}`
This would create an archive with the name: `Test chapter--2.cbz`
So to fix this issue you need to set the `--name-format-none` flag.
`python3 manga-dlp.py <other options> --format "cbz" --name-format "{chapter_name}-{chapter_vol}-{chapter_num} --name-format-none "0"`
This will create an archive with the name: `Test chapter-0-2.cbz`
## Read links from a file
With the option `--read` you can specify a file with links to multiple mangas. They will be parsed from top to bottom
one at a time. Every link will be matched for the right api to use. It is important that you only have one link per
line, otherwise they can't be parsed.
**Example:**
```txt
# mangas.txt
link1
link2
link3
```
`python3 manga-dlp.py --read mangas.txt --list`
This will list all available chapters for link1, link2 and link3.
## Create basic cache
With the `--cache-path <cache file>` option you can let the script create a very basic json cache. Your downloaded
chapters will be
tracked there, and the script doesn't have to check on disk if you already downloaded it.
If the option is unset (default), then no caching will be done.
## Add metadata
manga-dlp supports the creation of metadata files in the downloaded chapter.
The metadata is based on the newer [ComicRack/Anansi](https://anansi-project.github.io/docs/introduction) standard.
The default option is to add the metadata in the folder/archive with the name `ComicInfo.xml`.
If you don't want metadata, you can pass the `--no-metadata` flag.
> pdf format does not support metadata at the moment

View file

@ -1,74 +0,0 @@
# Hooks
## Available hooks
You can run custom hooks with manga-dlp for specific events.
They are run with the `subproccess.run` function, so they get run directly by your operating system.
The available hook events are:
- **Pre Manga** -> Before anything gets downloaded
- **Pre Chapter** -> Before the chapter gets downloaded
- **Post Manga** -> After the manga is done. (All specified chapters were downloaded)
- **Post Chapter** -> After each chapter was downloaded (and formatted if specified)
Each of these hooks can be set with a specific flag:
- `--hook-pre-manga` -> Pre Manga hook
- `--hook-pre-chapter` -> Pre Chapter hook
- `--hook-post-manga` -> Post Manga hook
- `--hook-post-chapter` -> Post Chapter hook
**Example:**
```sh
manga-dlp -u <some url> -c 1 --hook-post-manga <some command>
# echo "abc" to stdout
manga-dlp -u <some url> -c 1 --hook-post-manga "echo abc"
# echo the manga name to stdout
manga-dlp -u <some url> -c 1 --hook-post-manga "echo ${MDLP_MANGA_TITLE}"
```
## Env Variables
All hooks are exposed to a variety of environment variables with infos about the manga/chapter currently downloading.
All available env variables are listed below with the example
for [this](https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie) manga:
> Command
>
used: `python3 manga-dlp.py -u https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie -c 1`
**General:**
- `MDLP_HOOK_TYPE` -> manga_pre / manga_post / chapter_pre / chapter_post
- `MDLP_STATUS` -> starting / success / error / none
- `MDLP_REASON` -> none or the reason of the status
**Manga hooks:**
- `MDLP_API` -> Mangadex
- `MDLP_MANGA_URL_UUID` -> https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie
- `MDLP_MANGA_UUID` -> 0aea9f43-d4a9-4bf7-bebc-550a512f9b95
- `MDLP_MANGA_TITLE` -> Shikimori's Not Just a Cutie
- `MDLP_LANGUAGE` -> en
- `MDLP_TOTAL_CHAPTERS` -> 158
- `MDLP_CHAPTERS_TO_DOWNLOAD` -> ['1']
- `MDLP_FILE_FORMAT` -> .cbz
- `MDLP_FORCEVOL` -> False
- `MDLP_DOWNLOAD_PATH` -> downloads
- `MDLP_MANGA_PATH` -> downloads/Shikimori's Not Just a Cutie
**Chapter hooks (extends Manga hooks env variables):**
- `MDLP_CHAPTER_FILENAME` -> Ch. 1
- `MDLP_CHAPTER_PATH` -> downloads/Shikimori's Not Just a Cutie/Ch. 1
- `MDLP_CHAPTER_ARCHIVE_PATH` -> downloads/Shikimori's Not Just a Cutie/Ch. 1.cbz
- `MDLP_CHAPTER_UUID` -> b7cba066-0b45-4d88-be08-89240841b4f7
- `MDLP_CHAPTER_VOLUME` -> 1
- `MDLP_CHAPTER_NUMBER` -> 1
- `MDLP_CHAPTER_NAME` -> `empty string`

View file

@ -1,153 +0,0 @@
# manga-dlp - python script to download mangas
CI/CD
[![status-badge](https://img.shields.io/drone/build/olofvndrhr/manga-dlp?label=tests&server=https%3A%2F%2Fci.44net.ch)](https://ci.44net.ch/olofvndrhr/manga-dlp)
[![Last Release](https://img.shields.io/github/release-date/olofvndrhr/manga-DLP?label=last%20release)](https://github.com/olofvndrhr/manga-dlp/releases)
[![Version](https://img.shields.io/github/v/release/olofvndrhr/manga-dlp?label=git%20release)](https://github.com/olofvndrhr/manga-dlp/releases)
[![Version PyPi](https://img.shields.io/pypi/v/manga-dlp?label=pypi%20release)](https://pypi.org/project/manga-dlp/)
Code Analysis
[![Quality Gate Status](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=alert_status&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Coverage](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=coverage&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Bugs](https://sonarqube.44net.ch/api/project_badges/measure?project=olofvndrhr%3Amanga-dlp&metric=bugs&token=f9558470580eea5b4899cf33f190eee16011346d)](https://sonarqube.44net.ch/dashboard?id=olofvndrhr%3Amanga-dlp)
[![Security](https://img.shields.io/snyk/vulnerabilities/github/olofvndrhr/manga-dlp)](https://app.snyk.io/org/olofvndrhr-t6h/project/aae9609d-a4e4-41f8-b1ac-f2561b2ad4e3)
Meta
[![Code style](https://img.shields.io/badge/code%20style-black-black)](https://github.com/psf/black)
[![Linter](https://img.shields.io/badge/linter-ruff-red)](https://github.com/charliermarsh/ruff)
[![Types](https://img.shields.io/badge/types-pyright-blue)](https://github.com/microsoft/pyright)
[![Tests](https://img.shields.io/badge/tests-pytest%20%7C%20tox-yellow)](https://github.com/pytest-dev/pytest/)
[![Coverage](https://img.shields.io/badge/coverage-coveragepy-green)](https://github.com/nedbat/coveragepy)
[![License](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://snyk.io/learn/what-is-mit-license/)
[![Compatibility](https://img.shields.io/pypi/pyversions/manga-dlp)](https://pypi.org/project/manga-dlp/)
---
## Description
A manga download script written in python. It only supports [mangadex.org](https://mangadex.org/) for now. But support
for other sites is _planned™_.
Before downloading a new chapter, the script always checks if there is already a chapter with the same name in the
download directory. If found the chapter is skipped. So you can run the script on a schedule to only download new
chapters without any additional setup.
The default behaiviour is to pack the images to a [cbz archive](https://en.wikipedia.org/wiki/Comic_book_archive). If
you just want the folder with all the pictures use the flag `--format ""`.
## _Currently_ Supported sites
- [Mangadex.org](https://mangadex.org/)
## Features (not complete)
- Metadata support with [ComicInfo.xml](https://anansi-project.github.io/docs/comicinfo/intro)
- Json caching
- Custom hooks after/before each download
- Custom chapter name format
- Volume support
- Multiple archive formats supported (cbz,cbr,zip,none)
- Language selection
- Download all chapters directly
- And others...
## Usage
### Quick start
```sh
python3 manga-dlp.py \
--url https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu \
--language "en" \
--chapters "all"
```
### With GitHub
```sh
git clone https://github.com/olofvndrhr/manga-dlp.git # clone the repository
cd manga-dlp # go in the directory
pip install -r requirements.txt # install required packages
# on windows
python manga-dlp.py <options>
# on unix
python3 manga-dlp.py <options>
```
### With pip ([pypi](https://pypi.org/project/manga-dlp/))
```sh
python3 -m pip install manga-dlp # download the package from pypi
python3 -m mangadlp <args> # start the script as a module
OR
manga-dlp <args> # call script directly
OR
mangadlp <args> # call script directly
```
### With docker
See the docker [README](https://manga-dlp.ivn.sh/docker/)
## Options
```txt
Usage: manga-dlp.py [OPTIONS]
Script to download mangas from various sites
Options:
--help Show this message and exit.
--version Show the version and exit.
source: [mutually_exclusive, required]
-u, --url, --uuid TEXT URL or UUID of the manga
--read FILE Path of file with manga links to download. One per line
verbosity: [mutually_exclusive]
--loglevel INTEGER Custom log level
--warn Only log warnings and higher
--debug Debug logging. Log EVERYTHING
-c, --chapters TEXT Chapters to download
-p, --path PATH Download path [default: downloads]
-l, --language TEXT Manga language [default: en]
--list List all available chapters
--format [cbz|cbr|zip|pdf|] Archive format to create. An empty string means don't archive the folder [default: cbz]
--name-format TEXT Naming format to use when saving chapters. See docs for more infos [default: {default}]
--name-format-none TEXT String to use when the variable of the custom name format is empty
--forcevol Force naming of volumes. For mangas where chapters reset each volume
--wait FLOAT Time to wait for each picture to download in seconds(float) [default: 0.5]
--hook-manga-pre TEXT Commands to execute before the manga download starts
--hook-manga-post TEXT Commands to execute after the manga download finished
--hook-chapter-pre TEXT Commands to execute before the chapter download starts
--hook-chapter-post TEXT Commands to execute after the chapter download finished
--cache-path PATH Where to store the cache-db. If no path is given, cache is disabled
--add-metadata / --no-metadata Enable/disable creation of metadata via ComicInfo.xml [default: add-metadata]
```
## Contribution / Bugs
For suggestions for improvement, just open a pull request.
If you want to add support for a new site, there is an api [template file](https://github.com/olofvndrhr/manga-dlp/tree/master/contrib/api_template.py) which you can use.
And more infos and tools are in the contrib [README.md](https://github.com/olofvndrhr/manga-dlp/tree/master/contrib/README.md)
Otherwise, you can open an issue with the name of the site which you want support for (not guaranteed to be
implemented).
If you encounter any bugs, also just open an issue with a description of the problem.
## TODO's
- <del>Make docker container for easy distribution</del>
--> [Dockerhub](https://hub.docker.com/r/olofvndrhr/manga-dlp)
- <del>Automate release</del>
--> Done with woodpecker-ci
- <del>Make pypi package</del>
--> Done with release [2.1.7](https://pypi.org/project/manga-dlp/)
- Add more supported sites

46
get_release_notes.sh Executable file
View file

@ -0,0 +1,46 @@
#!/bin/bash
# shellcheck disable=SC2016
# script to extract the release notes from the changelog
# show script help
function show_help() {
cat << EOF
Script to generate release-notes from a changelog (CHANGELOG.md)
Usage:
./get_release_notes.sh <new_version>
Example:
./get_release_notes.sh "2.0.5"
EOF
exit 0
}
# create changelog for release
function get_release_notes() {
local l_version="${1}"
printf 'Creating release-notes\n'
# check for version
if [[ -z "${l_version}" ]]; then
printf 'You need to specify a version with $1\n'
exit 1
fi
awk -v ver="[${l_version}]" \
'/^## / { if (p) { exit }; if ($2 == ver) { p=1 } } p && NF' \
'CHANGELOG.md' > 'RELEASENOTES.md'
printf 'Done\n'
}
# check options
case "${1}" in
'--help' | '-h' | 'help')
show_help
;;
*)
get_release_notes "${@}"
;;
esac

158
justfile
View file

@ -3,73 +3,135 @@
default: show_receipts default: show_receipts
set shell := ["bash", "-uc"] set shell := ["bash", "-uc"]
set dotenv-load set dotenv-load := true
#set export
# aliases
alias s := show_receipts
alias i := show_system_info
alias p := prepare_workspace
alias l := lint
alias t := tests
alias f := tests_full
# variables
export asdf_version := "v0.10.2"
# default recipe to display help information
show_receipts: show_receipts:
just --list @just --list
show_system_info: show_system_info:
@echo "==================================" @echo "=================================="
@echo "os : {{os()}}" @echo "os : {{os()}}"
@echo "arch: {{arch()}}" @echo "arch: {{arch()}}"
@echo "justfile dir: {{justfile_directory()}}" @echo "home: ${HOME}"
@echo "invocation dir: {{invocation_directory()}}" @echo "project dir: {{justfile_directory()}}"
@echo "running dir: `pwd -P`"
@echo "==================================" @echo "=================================="
setup: check_asdf:
asdf install @if ! asdf --version; then \
lefthook install just install_asdf \
;else \
echo "asdf already installed" \
;fi
just install_asdf_bins
install_asdf:
@echo "installing asdf"
@echo "asdf version: ${asdf_version}"
@git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch "${asdf_version}"
@echo "adding asdf to .bashrc"
@if ! grep -q ".asdf/asdf.sh" "${HOME}/.bashrc"; then \
echo -e '\n# source asdf' >> "${HOME}/.bashrc" \
;echo 'source "${HOME}/.asdf/asdf.sh"' >> "${HOME}/.bashrc" \
;echo -e 'source "${HOME}/.asdf/completions/asdf.bash"\n' >> "${HOME}/.bashrc" \
;fi
@echo "to load asdf either restart your shell or do: 'source \${HOME}/.bashrc'"
setup_asdf:
@echo "installing asdf bins"
# add plugins
@if ! asdf plugin add python; then :; fi
@if ! asdf plugin add shfmt; then :; fi
@if ! asdf plugin add shellcheck; then :; fi
@if ! asdf plugin add just https://github.com/franklad/asdf-just; then :; fi
@if ! asdf plugin add direnv; then :; fi
# install bins
@if ! asdf install; then :; fi
# setup direnv
@if ! asdf direnv setup --shell bash --version latest; then :; fi
create_venv: create_venv:
@echo "creating venv" @echo "creating venv"
python3 -m pip install --upgrade pip setuptools wheel @python3 -m pip install --upgrade pip setuptools wheel
python3 -m venv venv @python3 -m venv venv
install_deps:
@echo "installing dependencies"
python3 -m hatch dep show requirements --project-only > /tmp/requirements.txt
pip3 install -r /tmp/requirements.txt
install_deps_dev:
@echo "installing dev dependencies"
python3 -m hatch dep show requirements --project-only > /tmp/requirements.txt
python3 -m hatch dep show requirements --env-only >> /tmp/requirements.txt
pip3 install -r /tmp/requirements.txt
create_reqs:
@echo "creating requirements"
pipreqs --force --savepath requirements.txt src/mangadlp/
test_shfmt: test_shfmt:
find . -type f \( -name "**.sh" -and -not -path "./.**" -and -not -path "./venv**" \) -exec shfmt -d -i 4 -bn -ci -sr "{}" \+; @find . -type f \( -name "**.sh" -and -not -path "./venv/*" -and -not -path "./.tox/*" \) -exec shfmt -d -i 4 -bn -ci -sr "{}" \+;
format_shfmt: test_black:
find . -type f \( -name "**.sh" -and -not -path "./.**" -and -not -path "./venv**" \) -exec shfmt -w -i 4 -bn -ci -sr "{}" \+; @python3 -m black --check --diff .
test_isort:
@python3 -m isort --check-only --diff .
test_mypy:
@python3 -m mypy --install-types --non-interactive mangadlp/
test_pytest:
@python3 -m tox -e basic
test_tox:
@python3 -m tox
test_tox_coverage:
@python3 -m tox -e coverage
test_build:
@python3 -m hatch build
test_ci_conf:
@woodpecker-cli lint .woodpecker/
test_docker_build:
@docker build . -f docker/Dockerfile.amd64 -t manga-dlp:test
# install dependecies and set everything up
prepare_workspace:
just show_system_info
just check_asdf
just setup_asdf
just create_venv
lint: lint:
just show_system_info just show_system_info
-just test_ci_conf
just test_shfmt just test_shfmt
hatch run lint:style just test_black
hatch run lint:typing just test_isort
just test_mypy
@echo -e "\n\033[0;32m=== ALL DONE ===\033[0m\n"
format: tests:
just show_system_info just show_system_info
just format_shfmt -just test_ci_conf
hatch run lint:fmt just test_shfmt
just test_black
just test_isort
just test_mypy
just test_pytest
@echo -e "\n\033[0;32m=== ALL DONE ===\033[0m\n"
check: tests_full:
just lint just show_system_info
just format -just test_ci_conf
just test_shfmt
test: just test_black
hatch run default:test just test_isort
just test_mypy
coverage: just test_build
hatch run default:cov just test_tox
just test_tox_coverage
build: just test_docker_build
hatch build --clean @echo -e "\n\033[0;32m=== ALL DONE ===\033[0m\n"
run loglevel *flags:
hatch run mangadlp --loglevel {{loglevel}} {{flags}}

View file

@ -1,7 +1,6 @@
import sys from mangadlp.input import main
import src.mangadlp.cli
MDLP_VERSION = "2.1.10"
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(src.mangadlp.cli.main()) main()

22
mangadlp/__init__.py Normal file
View file

@ -0,0 +1,22 @@
import logging
from mangadlp.logger import logger_lean, logger_verbose
# prepare logger with default level INFO==20
logging.basicConfig(
format="%(asctime)s | %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
level=20,
handlers=[logging.StreamHandler()],
)
# create custom log levels
logging.addLevelName(15, "VERBOSE")
logging.VERBOSE = 15 # type: ignore
logging.verbose = logger_verbose # type: ignore
logging.Logger.verbose = logger_verbose # type: ignore
logging.addLevelName(25, "LEAN")
logging.VERBOSE = 25 # type: ignore
logging.lean = logger_lean # type: ignore
logging.Logger.lean = logger_lean # type: ignore

6
mangadlp/__main__.py Normal file
View file

@ -0,0 +1,6 @@
import sys
from mangadlp.input import main
if __name__ == "__main__":
sys.exit(main())

0
mangadlp/api/__init__.py Normal file
View file

250
mangadlp/api/mangadex.py Normal file
View file

@ -0,0 +1,250 @@
import logging
import re
import sys
from time import sleep
from typing import Any
import requests
import mangadlp.utils as utils
class Mangadex:
# api information
api_base_url = "https://api.mangadex.org"
img_base_url = "https://uploads.mangadex.org"
# get infos to initiate class
def __init__(self, url_uuid: str, language: str, forcevol: bool):
# static info
self.url_uuid = url_uuid
self.language = language
self.forcevol = forcevol
# api stuff
self.api_content_ratings = "contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
self.api_language = f"translatedLanguage[]={self.language}"
self.api_additions = f"{self.api_language}&{self.api_content_ratings}"
# infos from functions
self.manga_uuid = self.get_manga_uuid()
self.manga_data = self.get_manga_data()
self.manga_title = self.get_manga_title()
self.manga_chapter_data = self.get_chapter_data()
self.chapter_list = self.create_chapter_list()
# make initial request
def get_manga_data(self) -> requests.Response:
logging.verbose(f"Getting manga data for: {self.manga_uuid}") # type: ignore
counter = 1
while counter <= 3:
try:
manga_data = requests.get(
f"{self.api_base_url}/manga/{self.manga_uuid}"
)
except:
if counter >= 3:
logging.error("Maybe the MangaDex API is down?")
sys.exit(1)
else:
logging.error("Mangadex API not reachable. Retrying")
sleep(2)
counter += 1
else:
break
# check if manga exists
if manga_data.json()["result"] != "ok":
logging.error("Manga not found")
sys.exit(1)
return manga_data
# get the uuid for the manga
def get_manga_uuid(self) -> str:
# isolate id from url
uuid_regex: Any = re.compile(
"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}"
)
# check for new mangadex id
if not uuid_regex.search(self.url_uuid):
logging.error("No valid UUID found")
sys.exit(1)
manga_uuid = uuid_regex.search(self.url_uuid)[0]
return manga_uuid
# get the title of the manga (and fix the filename)
def get_manga_title(self) -> str:
logging.verbose(f"Getting manga title for: {self.manga_uuid}") # type: ignore
manga_data = self.manga_data.json()
try:
title = manga_data["data"]["attributes"]["title"][self.language]
except:
# search in alt titles
try:
alt_titles = {}
for title in manga_data["data"]["attributes"]["altTitles"]:
alt_titles.update(title)
title = alt_titles[self.language]
except: # no title on requested language found
logging.error("Chapter in requested language not found.")
sys.exit(1)
return utils.fix_name(title)
# check if chapters are available in requested language
def check_chapter_lang(self) -> int:
logging.verbose( # type: ignore
f"Checking for chapters in specified language for: {self.manga_uuid}"
)
r = requests.get(
f"{self.api_base_url}/manga/{self.manga_uuid}/feed?limit=0&{self.api_additions}"
)
try:
total_chapters = r.json()["total"]
except:
logging.error(
"Error retrieving the chapters list. Did you specify a valid language code?"
)
return 0
else:
if total_chapters == 0:
logging.error("No chapters available to download!")
return 0
return total_chapters
# get chapter data like name, uuid etc
def get_chapter_data(self) -> dict:
logging.verbose(f"Getting chapter data for: {self.manga_uuid}") # type: ignore
api_sorting = "order[chapter]=asc&order[volume]=asc"
# check for chapters in specified lang
total_chapters = self.check_chapter_lang()
if total_chapters == 0:
sys.exit(1)
chapter_data = {}
last_chapter = ["", ""]
offset = 0
while offset < total_chapters: # if more than 500 chapters
r = requests.get(
f"{self.api_base_url}/manga/{self.manga_uuid}/feed?{api_sorting}&limit=500&offset={offset}&{self.api_additions}"
)
for chapter in r.json()["data"]:
# chapter infos from feed
chapter_num = chapter["attributes"]["chapter"]
chapter_vol = chapter["attributes"]["volume"]
chapter_uuid = chapter["id"]
chapter_name = chapter["attributes"]["title"]
chapter_external = chapter["attributes"]["externalUrl"]
# check for chapter title and fix it
if chapter_name is None:
chapter_name = "No Title"
else:
chapter_name = utils.fix_name(chapter_name)
# check if the chapter is external (can't download them)
if chapter_external is not None:
continue
# name chapter "oneshot" if there is no chapter number
if chapter_num is None:
chapter_num = "Oneshot"
# check if its duplicate from the last entry
if last_chapter[0] == chapter_vol and last_chapter[1] == chapter_num:
continue
# export chapter data as a dict
chapter_index = (
chapter_num if not self.forcevol else f"{chapter_vol}:{chapter_num}"
)
chapter_data[chapter_index] = [
chapter_uuid,
chapter_vol,
chapter_num,
chapter_name,
]
# add last chapter to duplicate check
last_chapter = [chapter_vol, chapter_num]
# increase offset for mangas with more than 500 chapters
offset += 500
return chapter_data
# get images for the chapter (mangadex@home)
def get_chapter_images(self, chapter: str, wait_time: float) -> list:
logging.verbose(f"Getting chapter images for: {self.manga_uuid}") # type: ignore
athome_url = f"{self.api_base_url}/at-home/server"
chapter_uuid = self.manga_chapter_data[chapter][0]
# retry up to two times if the api applied ratelimits
api_error = False
counter = 1
while counter <= 3:
try:
r = requests.get(f"{athome_url}/{chapter_uuid}")
api_data = r.json()
if api_data["result"] != "ok":
logging.error(f"No chapter with the id {chapter_uuid} found")
api_error = True
raise IndexError
elif api_data["chapter"]["data"] is None:
logging.error(f"No chapter data found for chapter {chapter_uuid}")
api_error = True
raise IndexError
else:
api_error = False
break
except:
if counter >= 3:
api_error = True
logging.error(f"Retrying in a few seconds")
counter += 1
sleep(wait_time + 2)
# check if result is ok
else:
if api_error:
return []
chapter_hash = api_data["chapter"]["hash"]
chapter_img_data = api_data["chapter"]["data"]
# get list of image urls
image_urls = []
for image in chapter_img_data:
image_urls.append(f"{self.img_base_url}/data/{chapter_hash}/{image}")
sleep(wait_time)
return image_urls
# create list of chapters
def create_chapter_list(self) -> list:
logging.verbose(f"Creating chapter list for: {self.manga_uuid}") # type: ignore
chapter_list = []
for chapter in self.manga_chapter_data.items():
chapter_info = self.get_chapter_infos(chapter[0])
chapter_number = chapter_info["chapter"]
volume_number = chapter_info["volume"]
if self.forcevol:
chapter_list.append(f"{volume_number}:{chapter_number}")
else:
chapter_list.append(chapter_number)
return chapter_list
# create easy to access chapter infos
def get_chapter_infos(self, chapter: str) -> dict:
logging.debug(
f"Getting chapter infos for: {self.manga_chapter_data[chapter][0]}"
)
chapter_uuid = self.manga_chapter_data[chapter][0]
chapter_vol = self.manga_chapter_data[chapter][1]
chapter_num = self.manga_chapter_data[chapter][2]
chapter_name = self.manga_chapter_data[chapter][3]
return {
"uuid": chapter_uuid,
"volume": chapter_vol,
"chapter": chapter_num,
"name": chapter_name,
}

298
mangadlp/app.py Normal file
View file

@ -0,0 +1,298 @@
import logging
import re
import shutil
import sys
from pathlib import Path
from typing import Any
import mangadlp.downloader as downloader
import mangadlp.utils as utils
# supported api's
from mangadlp.api.mangadex import Mangadex
class MangaDLP:
"""Download Mangas from supported sites.
After initialization, start the script with the function get_manga().
:param url_uuid: URL or UUID of the manga
:param language: Manga language with country codes. "en" --> english
:param chapters: Chapters to download, "all" for every chapter available
:param list_chapters: List all available chapters and exit
:param file_format: Archive format to create. An empty string means don't archive the folder
:param forcevol: Force naming of volumes. Useful for mangas where chapters reset each volume
:param download_path: Download path. Defaults to '<script_dir>/downloads'
:param download_wait: Time to wait for each picture to download in seconds
:param verbosity: Verbosity of the output. Uses the logging library values
:return: Nothing. Just the files
"""
def __init__(
self,
url_uuid: str,
language: str = "en",
chapters: str = "",
list_chapters: bool = False,
file_format: str = "cbz",
forcevol: bool = False,
download_path: str = "downloads",
download_wait: float = 0.5,
verbosity: int = 20,
) -> None:
# init parameters
self.url_uuid = url_uuid
self.language = language
self.chapters = chapters
self.list_chapters = list_chapters
self.file_format = file_format
self.forcevol = forcevol
self.download_path = download_path
self.download_wait = download_wait
self.verbosity = verbosity
# prepare everything
self._prepare()
def _prepare(self) -> None:
# set manga format suffix
if self.file_format and "." not in self.file_format:
self.file_format = f".{self.file_format}"
# start prechecks
self.pre_checks()
# init api
self.api_used = self.check_api(self.url_uuid)
self.api = self.api_used(self.url_uuid, self.language, self.forcevol)
# get manga title and uuid
self.manga_uuid = self.api.manga_uuid
self.manga_title = self.api.manga_title
# get chapter list
self.manga_chapter_list = self.api.chapter_list
self.manga_path = Path(f"{self.download_path}/{self.manga_title}")
def pre_checks(self) -> None:
# prechecks userinput/options
# no url and no readin list given
if not self.url_uuid:
logging.error(
'You need to specify a manga url/uuid with "-u" or a list with "--read"'
)
sys.exit(1)
# checks if --list is not used
if not self.list_chapters:
if self.chapters is None:
# no chapters to download were given
logging.error(
'You need to specify one or more chapters to download. To see all chapters use "--list"'
)
sys.exit(1)
# if forcevol is used, but didn't specify a volume in the chapters selected
if self.forcevol and ":" not in self.chapters:
logging.error("You need to specify the volume if you use --forcevol")
sys.exit(1)
# if forcevol is not used, but a volume is specified
if not self.forcevol and ":" in self.chapters:
logging.error("Don't specify the volume without --forcevol")
sys.exit(1)
# check the api which needs to be used
def check_api(self, url_uuid: str) -> type:
# apis to check
api_mangadex = re.compile("mangadex.org")
api_mangadex2 = re.compile(
"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}"
)
api_test = re.compile("test.test")
# check url for match
if api_mangadex.search(url_uuid) or api_mangadex2.search(url_uuid):
return Mangadex
# this is only for testing multiple apis
if api_test.search(url_uuid):
logging.critical("Not supported yet")
sys.exit(1)
# no supported api found
logging.error(f"No supported api in link/uuid found: {url_uuid}")
raise ValueError
# once called per manga
def get_manga(self) -> None:
# create empty skipped chapters list
skipped_chapters: list[Any] = []
error_chapters: list[Any] = []
print_divider = "========================================="
# show infos
logging.info(f"{print_divider}")
logging.lean(f"Manga Name: {self.manga_title}") # type: ignore
logging.info(f"Manga UUID: {self.manga_uuid}")
logging.info(f"Total chapters: {len(self.manga_chapter_list)}")
# list chapters if list_chapters is true
if self.list_chapters:
logging.info(f"Available Chapters: {', '.join(self.manga_chapter_list)}")
logging.info(f"{print_divider}\n")
return None
# check chapters to download if not all
if self.chapters.lower() == "all":
chapters_to_download = self.manga_chapter_list
else:
chapters_to_download = utils.get_chapter_list(
self.chapters, self.manga_chapter_list
)
# show chapters to download
logging.lean(f"Chapters selected: {', '.join(chapters_to_download)}") # type: ignore
logging.info(f"{print_divider}")
# create manga folder
self.manga_path.mkdir(parents=True, exist_ok=True)
# get chapters
for chapter in chapters_to_download:
return_infos = self.get_chapter(chapter)
error_chapters.append(return_infos.get("error"))
skipped_chapters.append(return_infos.get("skipped"))
if self.file_format and return_infos["chapter_path"]:
return_infos = self.archive_chapter(return_infos["chapter_path"])
error_chapters.append(return_infos.get("error"))
skipped_chapters.append(return_infos.get("skipped"))
# check if chapter was skipped
try:
return_infos["skipped"]
# chapter was not skipped
except KeyError:
# done with chapter
logging.info("Done with chapter\n")
# done with manga
logging.info(f"{print_divider}")
logging.lean(f"Done with manga: {self.manga_title}") # type: ignore
# filter skipped list
skipped_chapters = list(filter(None, skipped_chapters))
if len(skipped_chapters) >= 1:
logging.lean(f"Skipped chapters: {', '.join(skipped_chapters)}") # type: ignore
# filter error list
error_chapters = list(filter(None, error_chapters))
if len(error_chapters) >= 1:
logging.lean(f"Chapters with errors: {', '.join(error_chapters)}") # type: ignore
logging.info(f"{print_divider}\n")
# once called per chapter
def get_chapter(self, chapter: str) -> dict:
# get chapter infos
chapter_infos = self.api.get_chapter_infos(chapter)
# get image urls for chapter
try:
chapter_image_urls = self.api.get_chapter_images(
chapter, self.download_wait
)
except KeyboardInterrupt:
logging.critical("Stopping")
sys.exit(1)
# check if the image urls are empty. if yes skip this chapter (for mass downloads)
if not chapter_image_urls:
logging.error(
f"No images: Skipping Vol. {chapter_infos['volume']} Ch.{chapter_infos['chapter']}"
)
# add to skipped chapters list
return (
{
"error": f"{chapter_infos['volume']}:{chapter_infos['chapter']}",
"chapter_path": None,
}
if self.forcevol
else {"error": f"{chapter_infos['chapter']}", "chapter_path": None}
)
# get filename for chapter (without suffix)
chapter_filename = utils.get_filename(
chapter_infos["name"], chapter_infos["volume"], chapter, self.forcevol
)
# set download path for chapter (image folder)
chapter_path = self.manga_path / chapter_filename
# set archive path with file format
chapter_archive_path = Path(f"{chapter_path}{self.file_format}")
# check if chapter already exists
# check for folder, if file format is an empty string
if chapter_archive_path.exists():
if self.verbosity != "lean":
logging.warning(f"'{chapter_archive_path}' already exists. Skipping")
# add to skipped chapters list
return (
{
"skipped": f"{chapter_infos['volume']}:{chapter_infos['chapter']}",
"chapter_path": None,
}
if self.forcevol
else {"skipped": f"{chapter_infos['chapter']}", "chapter_path": None}
)
# create chapter folder (skips it if it already exists)
chapter_path.mkdir(parents=True, exist_ok=True)
# verbose log
logging.verbose(f"Chapter UUID: {chapter_infos['uuid']}") # type: ignore
logging.verbose(f"Filename: '{chapter_archive_path.name}'") # type: ignore
logging.verbose(f"File path: '{chapter_archive_path}'") # type: ignore
logging.verbose(f"Image URLS:\n{chapter_image_urls}") # type: ignore
# log
logging.lean(f"Downloading: '{chapter_filename}'") # type: ignore
# download images
try:
downloader.download_chapter(
chapter_image_urls, chapter_path, self.download_wait
)
except KeyboardInterrupt:
logging.critical("Stopping")
sys.exit(1)
except:
logging.error(f"Cant download: '{chapter_filename}'. Skipping")
# add to skipped chapters list
return (
{
"error": f"{chapter_infos['volume']}:{chapter_infos['chapter']}",
"chapter_path": None,
}
if self.forcevol
else {"error": f"{chapter_infos['chapter']}", "chapter_path": None}
)
else:
# Done with chapter
logging.lean(f"INFO: Successfully downloaded: '{chapter_filename}'") # type: ignore
return {"chapter_path": chapter_path}
# create an archive of the chapter if needed
def archive_chapter(self, chapter_path: Path) -> dict:
logging.lean(f"INFO: Creating '{self.file_format}' archive") # type: ignore
try:
# check if image folder is existing
if not chapter_path.exists():
logging.error(f"Image folder: {chapter_path} does not exist")
raise IOError
if self.file_format == ".pdf":
utils.make_pdf(chapter_path)
else:
utils.make_archive(chapter_path, self.file_format)
except:
logging.error(f"Archive error. Skipping chapter")
# add to skipped chapters list
return {
"error": chapter_path,
}
else:
# remove image folder
shutil.rmtree(chapter_path)
return {}

View file

@ -1,18 +1,18 @@
import logging import logging
import shutil import shutil
import sys
from pathlib import Path from pathlib import Path
from time import sleep from time import sleep
from typing import List, Union from typing import Union
import requests import requests
from loguru import logger as log
from mangadlp import utils import mangadlp.utils as utils
# download images # download images
def download_chapter( def download_chapter(
image_urls: List[str], image_urls: list,
chapter_path: Union[str, Path], chapter_path: Union[str, Path],
download_wait: float, download_wait: float,
) -> None: ) -> None:
@ -25,21 +25,22 @@ def download_chapter(
# show progress bar for default log level # show progress bar for default log level
if logging.root.level == logging.INFO: if logging.root.level == logging.INFO:
utils.progress_bar(image_num, total_img) utils.progress_bar(image_num, total_img)
log.debug(f"Downloading image {image_num}/{total_img}") logging.verbose(f"Downloading image {image_num}/{total_img}") # type: ignore
counter = 1 counter = 1
while counter <= 3: while counter <= 3:
try: try:
r = requests.get(image, timeout=10, stream=True) r = requests.get(image, stream=True)
if r.status_code != 200: if r.status_code != 200:
log.error(f"Request for image {image} failed, retrying") logging.error(f"Request for image {image} failed, retrying")
raise ConnectionError raise ConnectionError
except KeyboardInterrupt as exc: except KeyboardInterrupt:
raise exc logging.critical("Stopping")
except Exception as exc: sys.exit(1)
except:
if counter >= 3: if counter >= 3:
log.error("Maybe the MangaDex Servers are down?") logging.error("Maybe the MangaDex Servers are down?")
raise exc raise ConnectionError
sleep(download_wait) sleep(download_wait)
counter += 1 counter += 1
else: else:
@ -50,8 +51,9 @@ def download_chapter(
with image_path.open("wb") as file: with image_path.open("wb") as file:
r.raw.decode_content = True r.raw.decode_content = True
shutil.copyfileobj(r.raw, file) shutil.copyfileobj(r.raw, file)
except Exception as exc: except:
log.error("Can't write file") logging.error("Can't write file")
raise exc raise IOError
image_num += 1
sleep(download_wait) sleep(download_wait)

225
mangadlp/input.py Normal file
View file

@ -0,0 +1,225 @@
import argparse
import sys
from pathlib import Path
import mangadlp.app as app
import mangadlp.logger as logger
MDLP_VERSION = "2.1.10"
def check_args(args):
# check if --version was used
if args.version:
print(f"manga-dlp version: {MDLP_VERSION}")
sys.exit(0)
# check if a readin list was provided
if not args.read:
# single manga, no readin list
call_app(args)
else:
# multiple mangas
url_list = readin_list(args.read)
for url in url_list:
args.url_uuid = url
call_app(args)
# read in the list of links from a file
def readin_list(readlist: str) -> list:
list_file = Path(readlist)
try:
url_str = list_file.read_text()
url_list = url_str.splitlines()
except:
raise IOError
return url_list
def call_app(args):
# set logger formatting
logger.format_logger(args.verbosity)
# call main function with all input arguments
mdlp = app.MangaDLP(
args.url_uuid,
args.lang,
args.chapters,
args.list,
args.format,
args.forcevol,
args.path,
args.wait,
args.verbosity,
)
mdlp.get_manga()
def get_input():
print(f"manga-dlp version: {MDLP_VERSION}")
print("Enter details of the manga you want to download:")
while True:
try:
url_uuid = str(input("Url or UUID: "))
readlist = str(input("List with links (optional): "))
language = str(input("Language: ")) or "en"
list_chapters = str(input("List chapters? y/N: "))
if list_chapters.lower() != "y" or list_chapters.lower() != "yes":
chapters = str(input("Chapters: "))
except KeyboardInterrupt:
sys.exit(1)
except:
continue
else:
break
args = [
"-l",
language,
"-c",
chapters,
]
if url_uuid:
args.append("-u")
args.append(url_uuid)
if readlist:
args.append("--read")
args.append(readlist)
if list_chapters.lower() == "y" or list_chapters.lower() == "yes":
args.append("--list")
# start script again with the arguments
sys.argv.extend(args)
get_args()
def get_args():
parser = argparse.ArgumentParser(
description="Script to download mangas from various sites"
)
action = parser.add_mutually_exclusive_group(required=True)
verbosity = parser.add_mutually_exclusive_group(required=False)
action.add_argument(
"-u",
"--url",
"--uuid",
dest="url_uuid",
required=False,
help="URL or UUID of the manga",
action="store",
)
action.add_argument(
"--read",
dest="read",
required=False,
help="Path of file with manga links to download. One per line",
action="store",
)
action.add_argument(
"-v",
"--version",
dest="version",
required=False,
help="Show version of manga-dlp and exit",
action="store_true",
)
parser.add_argument(
"-c",
"--chapters",
dest="chapters",
required=False,
help="Chapters to download",
action="store",
)
parser.add_argument(
"-p",
"--path",
dest="path",
required=False,
help='Download path. Defaults to "<script_dir>/downloads"',
action="store",
default="downloads",
)
parser.add_argument(
"-l",
"--language",
dest="lang",
required=False,
help='Manga language. Defaults to "en" --> english',
action="store",
default="en",
)
parser.add_argument(
"--list",
dest="list",
required=False,
help="List all available chapters. Defaults to false",
action="store_true",
)
parser.add_argument(
"--format",
dest="format",
required=False,
help="Archive format to create. An empty string means dont archive the folder. Defaults to 'cbz'",
action="store",
default="cbz",
)
parser.add_argument(
"--forcevol",
dest="forcevol",
required=False,
help="Force naming of volumes. For mangas where chapters reset each volume",
action="store_true",
)
parser.add_argument(
"--wait",
dest="wait",
required=False,
type=float,
default=0.5,
help="Time to wait for each picture to download in seconds(float). Defaults 0.5",
)
verbosity.add_argument(
"--lean",
dest="verbosity",
required=False,
help="Lean logging. Minimal log output. Defaults to false",
action="store_const",
const=25,
default=20,
)
verbosity.add_argument(
"--verbose",
dest="verbosity",
required=False,
help="Verbose logging. More log output. Defaults to false",
action="store_const",
const=15,
default=20,
)
verbosity.add_argument(
"--debug",
dest="verbosity",
required=False,
help="Debug logging. Most log output. Defaults to false",
action="store_const",
const=10,
default=20,
)
# parser.print_help()
args = parser.parse_args()
check_args(args)
def main():
if len(sys.argv) > 1:
get_args()
else:
get_input()
if __name__ == "__main__":
main()

32
mangadlp/logger.py Normal file
View file

@ -0,0 +1,32 @@
import logging
# set log message format
def format_logger(verbosity: int):
logging.getLogger().setLevel(verbosity)
# dont show log level name on default/lean logging
if verbosity >= 20:
logging.basicConfig(
format="%(asctime)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
else:
logging.basicConfig(
format="%(asctime)s | %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
# create verbose logger with level 15
def logger_verbose(msg, *args, **kwargs):
if logging.getLogger().isEnabledFor(15):
logging.log(15, msg)
# create lean logger with level 25
def logger_lean(msg, *args, **kwargs):
if logging.getLogger().isEnabledFor(25):
logging.log(25, msg)

View file

@ -1,12 +1,10 @@
import logging
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, List from typing import Any
from zipfile import ZipFile from zipfile import ZipFile
import pytz
from loguru import logger as log
# create an archive of the chapter images # create an archive of the chapter images
def make_archive(chapter_path: Path, file_format: str) -> None: def make_archive(chapter_path: Path, file_format: str) -> None:
@ -17,40 +15,39 @@ def make_archive(chapter_path: Path, file_format: str) -> None:
for file in chapter_path.iterdir(): for file in chapter_path.iterdir():
zipfile.write(file, file.name) zipfile.write(file, file.name)
# rename zip to file format requested # rename zip to file format requested
zip_path.replace(zip_path.with_suffix(file_format)) zip_path.rename(zip_path.with_suffix(file_format))
except Exception as exc: except:
log.error(f"Can't create '{file_format}' archive") raise IOError
raise exc
def make_pdf(chapter_path: Path) -> None: def make_pdf(chapter_path: Path) -> None:
try: try:
import img2pdf # pylint: disable=import-outside-toplevel import img2pdf
except Exception as exc: except:
log.error("Cant import img2pdf. Please install it first") logging.error("Cant import img2pdf. Please install it first")
raise exc raise ImportError
pdf_path = Path(f"{chapter_path}.pdf") pdf_path = Path(f"{chapter_path}.pdf")
images: List[str] = [] images = []
for file in chapter_path.iterdir(): for file in chapter_path.iterdir():
images.append(str(file)) images.append(str(file))
try: try:
pdf_path.write_bytes(img2pdf.convert(images)) pdf_path.write_bytes(img2pdf.convert(images))
except Exception as exc: except:
log.error("Can't create '.pdf' archive") logging.error("Can't create '.pdf' archive")
raise exc raise IOError
# create a list of chapters # create a list of chapters
def get_chapter_list(chapters: str, available_chapters: List[str]) -> List[str]: def get_chapter_list(chapters: str, available_chapters: list) -> list:
# check if there are available chapter # check if there are available chapter
chapter_list: List[str] = [] chapter_list: list[str] = []
for chapter in chapters.split(","): for chapter in chapters.split(","):
# check if chapter list is with volumes and ranges (forcevol) # check if chapter list is with volumes and ranges (forcevol)
if "-" in chapter and ":" in chapter: if "-" in chapter and ":" in chapter:
# split chapters and volumes apart for list generation # split chapters and volumes apart for list generation
lower_num_fv: List[str] = chapter.split("-")[0].split(":") lower_num_fv: list[str] = chapter.split("-")[0].split(":")
upper_num_fv: List[str] = chapter.split("-")[1].split(":") upper_num_fv: list[str] = chapter.split("-")[1].split(":")
vol_fv: str = lower_num_fv[0] vol_fv: str = lower_num_fv[0]
chap_beg_fv: int = int(lower_num_fv[1]) chap_beg_fv: int = int(lower_num_fv[1])
chap_end_fv: int = int(upper_num_fv[1]) chap_end_fv: int = int(upper_num_fv[1])
@ -71,7 +68,7 @@ def get_chapter_list(chapters: str, available_chapters: List[str]) -> List[str]:
# select all chapters from the volume --> 1: == 1:1,1:2,1:3... # select all chapters from the volume --> 1: == 1:1,1:2,1:3...
if vol_num and not chap_num: if vol_num and not chap_num:
regex: Any = re.compile(f"{vol_num}:[0-9]{{1,4}}") regex: Any = re.compile(f"{vol_num}:[0-9]{{1,4}}")
vol_list: List[str] = [n for n in available_chapters if regex.match(n)] vol_list: list[str] = [n for n in available_chapters if regex.match(n)]
chapter_list.extend(vol_list) chapter_list.extend(vol_list)
else: else:
chapter_list.append(chapter) chapter_list.append(chapter)
@ -84,7 +81,6 @@ def get_chapter_list(chapters: str, available_chapters: List[str]) -> List[str]:
# remove illegal characters etc # remove illegal characters etc
def fix_name(filename: str) -> str: def fix_name(filename: str) -> str:
filename = filename.encode(encoding="utf8", errors="ignore").decode(encoding="utf8")
# remove illegal characters # remove illegal characters
filename = re.sub(r'[/\\<>:;|?*!@"]', "", filename) filename = re.sub(r'[/\\<>:;|?*!@"]', "", filename)
# remove multiple dots # remove multiple dots
@ -94,74 +90,34 @@ def fix_name(filename: str) -> str:
# remove trailing and beginning spaces # remove trailing and beginning spaces
filename = re.sub("([ \t]+$)|(^[ \t]+)", "", filename) filename = re.sub("([ \t]+$)|(^[ \t]+)", "", filename)
log.debug(f"Input name='{filename}', Output name='{filename}'")
return filename return filename
# create name for chapter # create name for chapter
def get_filename( def get_filename(
manga_title: str, chapter_name: str, chapter_vol: str, chapter_num: str, forcevol: bool
chapter_name: str,
chapter_vol: str,
chapter_num: str,
forcevol: bool,
name_format: str,
name_format_none: str,
) -> str: ) -> str:
# try to apply the custom format
if name_format != "{default}":
log.debug(f"Using custom name format: '{name_format}'")
try:
filename = name_format.format(
manga_title=manga_title or name_format_none,
chapter_name=chapter_name or name_format_none,
chapter_vol=chapter_vol or name_format_none,
chapter_num=chapter_num or name_format_none,
)
except Exception:
log.warning("File format is not valid. Falling back to default")
else:
return filename
# set vol to 0 if none found
chapter_vol = chapter_vol or "0"
# use default format
log.debug("Using default name format")
# if chapter is a oneshot # if chapter is a oneshot
if not chapter_num or "oneshot" in [chapter_name.lower(), chapter_num.lower()]: if chapter_name == "Oneshot" or chapter_num == "Oneshot":
return "Oneshot" return "Oneshot"
# if the chapter has no name # if the chapter has no name
if not chapter_name and forcevol:
return f"Vol. {chapter_vol} Ch. {chapter_num}"
if not chapter_name: if not chapter_name:
return f"Ch. {chapter_num}" return (
f"Vol. {chapter_vol} Ch. {chapter_num}"
if forcevol
else f"Ch. {chapter_num}"
)
# if the chapter has a name # if the chapter has a name
# return with volume if option is set, else just the chapter num and name # return with volume if option is set, else just the chapter num and name
if forcevol: return (
return f"Vol. {chapter_vol} Ch. {chapter_num} - {chapter_name}" f"Vol. {chapter_vol} Ch. {chapter_num} - {chapter_name}"
if forcevol
return f"Ch. {chapter_num} - {chapter_name}" else f"Ch. {chapter_num} - {chapter_name}"
)
def get_file_format(file_format: str) -> str:
if not file_format:
return ""
if re.match(r"\.?[a-z0-9]+", file_format, flags=re.I):
if file_format[0] != ".":
file_format = f".{file_format}"
else:
log.error(f"Invalid file format: '{file_format}'")
raise ValueError
return file_format
def progress_bar(progress: float, total: float) -> None: def progress_bar(progress: float, total: float) -> None:
time = datetime.now(tz=pytz.timezone("Europe/Zurich")).strftime("%Y-%m-%dT%H:%M:%S") time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
percent = int(progress / (int(total) / 100)) percent = int(progress / (int(total) / 100))
bar_length = 50 bar_length = 50
bar_progress = int(progress / (int(total) / bar_length)) bar_progress = int(progress / (int(total) / bar_length))
@ -169,9 +125,6 @@ def progress_bar(progress: float, total: float) -> None:
whitespace_texture = " " * (bar_length - bar_progress) whitespace_texture = " " * (bar_length - bar_progress)
if progress == total: if progress == total:
full_bar = "" * bar_length full_bar = "" * bar_length
print(f"\r{time}{' '*6}| [BAR ]{full_bar}❙ 100%", end="\n") # noqa print(f"\r{time} |{full_bar}❙ 100%", end="\n")
else: else:
print( # noqa print(f"\r{time} | ❙{bar_texture}{whitespace_texture}{percent}%", end="\r")
f"\r{time}{' '*6}| [BAR ] ❙{bar_texture}{whitespace_texture}{percent}%",
end="\r",
)

View file

@ -1,16 +1,22 @@
[build-system] [build-system]
requires = ["hatchling>=1.18", "hatch-regex-commit>=0.0.3"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
version = "2.1.10"
name = "manga-dlp" name = "manga-dlp"
description = "A cli manga downloader" description = "A cli manga downloader"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.8" requires-python = ">=3.8"
dynamic = ["version"] authors = [
authors = [{ name = "Ivan Schaller", email = "ivan@schaller.sh" }] { name = "Ivan Schaller", email = "ivan@schaller.sh" },
keywords = ["manga", "downloader", "mangadex"] ]
keywords = [
"manga",
"downloader",
"mangadex",
]
classifiers = [ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English", "Natural Language :: English",
@ -18,16 +24,9 @@ classifiers = [
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
] ]
dependencies = [ dependencies = [
"requests>=2.28.0", "requests>=2.24.0",
"loguru>=0.6.0",
"click>=8.1.3",
"click-option-group>=0.5.5",
"xmltodict>=0.13.0",
"img2pdf>=0.4.4",
"pytz>=2022.1",
] ]
[project.urls] [project.urls]
@ -37,210 +36,58 @@ Tracker = "https://github.com/olofvndrhr/manga-dlp/issues"
Source = "https://github.com/olofvndrhr/manga-dlp" Source = "https://github.com/olofvndrhr/manga-dlp"
[project.scripts] [project.scripts]
mangadlp = "mangadlp.cli:main" mangadlp = "mangadlp.input:main"
manga-dlp = "mangadlp.cli:main" manga-dlp = "mangadlp.input:main"
[tool.hatch.version] [tool.hatch.build]
source = "regex_commit" ignore-vcs = true
path = "src/mangadlp/__about__.py"
tag_sign = false
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
packages = ["src/mangadlp"] include = ["mangadlp"]
exclude = [
"docker",
".tox",
".mypy_cache"
]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/mangadlp"] include = ["mangadlp"]
###
### envs
###
[tool.hatch.envs.default]
python = "3.11"
dependencies = [
"pytest==7.4.3",
"coverage==7.3.2",
"xmltodict>=0.13.0",
"xmlschema>=2.2.1",
]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = ["coverage erase", "coverage run -m pytest {args:tests}"]
cov-report = ["- coverage combine", "coverage report", "coverage xml"]
cov = ["test-cov", "cov-report"]
[[tool.hatch.envs.lint.matrix]]
python = ["3.8", "3.9", "3.10", "3.11"]
[tool.hatch.envs.lint]
detached = true
dependencies = [
"mypy==1.8.0",
"ruff==0.2.2",
]
[tool.hatch.envs.lint.scripts]
typing = "mypy --non-interactive --install-types {args:src/mangadlp}"
style = [
"ruff check --diff {args:.}",
"ruff format --check --diff {args:.}"
]
fmt = [
"ruff check --fix {args:.}",
"ruff format {args:.}",
"style"
]
all = ["style", "typing"]
###
### ruff
###
[tool.ruff]
target-version = "py38"
line-length = 100
indent-width = 4
fix = true
show-fixes = true
respect-gitignore = true
src = ["src", "tests"]
exclude = [ exclude = [
".direnv", "docker",
".git",
".mypy_cache",
".ruff_cache",
".svn",
".tox", ".tox",
".nox", ".mypy_cache"
".venv",
"venv",
"__pypackages__",
"build",
"dist",
"node_modules",
"venv",
"contrib"
] ]
[tool.ruff.lint] [tool.isort]
select = [ py_version = 39
"A", skip_gitignore = true
"ARG", line_length = 88
"B", profile = "black"
"C", multi_line_output = 3
"DTZ", include_trailing_comma = true
"E", use_parentheses = true
"EM",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
]
ignore-init-module-imports = true
ignore = ["E501", "D103", "D100", "D102", "PLR2004", "D403", "ISC001", "FBT001", "FBT002", "FBT003", "W505"]
unfixable = ["F401"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "lf"
docstring-code-format = true
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["D104"]
"__about__.py" = ["D104", "F841"]
"tests/**/*" = ["PLR2004", "S101", "TID252", "T201", "ARG001", "S603", "S605"]
[tool.ruff.lint.pyupgrade]
keep-runtime-typing = true
[tool.ruff.lint.isort]
lines-after-imports = 2
known-first-party = ["mangadlp"]
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.lint.pylint]
max-branches = 24
max-returns = 12
max-statements = 100
max-args = 15
allow-magic-value-types = ["str", "bytes", "complex", "float", "int"]
[tool.ruff.lint.mccabe]
max-complexity = 15
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.pycodestyle]
max-doc-length = 100
###
### mypy
###
[tool.mypy] [tool.mypy]
#plugins = ["pydantic.mypy"] python_version = "3.9"
follow_imports = "silent" disallow_untyped_defs = false
warn_redundant_casts = true follow_imports = "normal"
warn_unused_ignores = true
disallow_any_generics = true
check_untyped_defs = true
no_implicit_reexport = true
ignore_missing_imports = true ignore_missing_imports = true
warn_return_any = true warn_no_return = false
pretty = true warn_unused_ignores = true
show_error_context = true
show_column_numbers = true show_column_numbers = true
show_error_codes = true show_error_codes = true
show_error_context = true pretty = true
#[tool.pydantic-mypy]
#init_forbid_extra = true
#init_typed = true
#warn_required_dynamic_aliases = true
###
### pytest
###
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = [
addopts = "--color=yes --exitfirst --verbose -ra" "."
filterwarnings = [
'ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning',
] ]
###
### coverage
###
[tool.coverage.run] [tool.coverage.run]
source_pkgs = ["mangadlp", "tests"] source = ["mangadlp"]
branch = true branch = true
parallel = true command_line = "-m pytest --exitfirst"
omit = ["src/mangadlp/__about__.py"]
[tool.coverage.paths]
mangadlp = ["src/mangadlp", "*/manga-dlp/src/mangadlp"]
tests = ["tests", "*/manga-dlp/tests"]
[tool.coverage.report] [tool.coverage.report]
# Regexes for lines to exclude from consideration # Regexes for lines to exclude from consideration
@ -258,7 +105,12 @@ exclude_lines = [
"if __name__ == .__main__.:", "if __name__ == .__main__.:",
# Don't complain about abstract methods, they aren't run: # Don't complain about abstract methods, they aren't run:
"@(abc.)?abstractmethod", "@(abc.)?abstractmethod",
"no cov",
"if TYPE_CHECKING:",
] ]
# ignore_errors = true ignore_errors = true
[tool.pylint.main]
py-version = "3.9"
[tool.pylint.logging]
logging-modules = ["logging"]
logging-format-style = "fstr"

View file

@ -1,4 +1,6 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>44net/renovate"] "extends": [
"local>44net-assets/docker-renovate-conf"
]
} }

View file

@ -1,7 +1,3 @@
requests>=2.28.0 requests>=2.24.0
loguru>=0.6.0
click>=8.1.3
click-option-group>=0.5.5
xmltodict>=0.13.0
img2pdf>=0.4.4 img2pdf>=0.4.4

View file

@ -5,8 +5,8 @@ sonar.links.scm=https://github.com/olofvndrhr/manga-dlp
sonar.links.issue=https://github.com/olofvndrhr/manga-dlp/issues sonar.links.issue=https://github.com/olofvndrhr/manga-dlp/issues
sonar.links.ci=https://ci.44net.ch/olofvndrhr/manga-dlp sonar.links.ci=https://ci.44net.ch/olofvndrhr/manga-dlp
# #
sonar.python.version=3.9 sonar.sources=mangadlp
sonar.sources=src/mangadlp
sonar.tests=tests sonar.tests=tests
#sonar.exclusions= sonar.exclusions=docker/**,contrib/**
sonar.python.version=3.9
sonar.python.coverage.reportPaths=coverage.xml sonar.python.coverage.reportPaths=coverage.xml

View file

@ -1 +0,0 @@
__version__ = "2.4.1"

View file

@ -1,7 +0,0 @@
import sys
import mangadlp.cli
if __name__ == "__main__":
sys.exit(mangadlp.cli.main())

View file

@ -1,288 +0,0 @@
import re
from time import sleep
from typing import Any, Dict, List
import requests
from loguru import logger as log
from mangadlp import utils
from mangadlp.models import ChapterData, ComicInfo
class Mangadex:
"""Mangadex API Class.
Get infos for a manga from mangadex.org.
Args:
url_uuid (str): URL or UUID of the manga
language (str): Manga language with country codes. "en" --> english
forcevol (bool): Force naming of volumes. Useful for mangas where chapters reset each volume
Attributes:
api_name (str): Name of the API
manga_uuid (str): UUID of the manga, without the url part
manga_data (dict): Infos of the manga. Name, title etc.
manga_title (str): The title of the manga, sanitized for all file systems
manga_chapter_data (dict): All chapter data of the manga. Volumes, chapters, chapter uuids and chapter names
chapter_list (list): A list of all available chapters for the language
"""
# api information
api_base_url = "https://api.mangadex.org"
img_base_url = "https://uploads.mangadex.org"
# get infos to initiate class
def __init__(self, url_uuid: str, language: str, forcevol: bool):
# static info
self.api_name = "Mangadex"
self.url_uuid = url_uuid
self.language = language
self.forcevol = forcevol
# api stuff
self.api_content_ratings = "contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
self.api_language = f"translatedLanguage[]={self.language}"
self.api_additions = f"{self.api_language}&{self.api_content_ratings}"
# infos from functions
self.manga_uuid = self.get_manga_uuid()
self.manga_data = self.get_manga_data()
self.manga_title = self.get_manga_title()
self.manga_chapter_data = self.get_chapter_data()
self.chapter_list = self.create_chapter_list()
# get the uuid for the manga
def get_manga_uuid(self) -> str:
# isolate id from url
uuid_regex = re.compile("[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}")
# try to get uuid in string
try:
uuid = uuid_regex.search(self.url_uuid)[0] # type: ignore
except Exception as exc:
log.error("No valid UUID found")
raise exc
return uuid
# make initial request
def get_manga_data(self) -> Dict[str, Any]:
log.debug(f"Getting manga data for: {self.manga_uuid}")
counter = 1
while counter <= 3:
try:
response = requests.get(f"{self.api_base_url}/manga/{self.manga_uuid}", timeout=10)
except Exception as exc:
if counter >= 3:
log.error("Maybe the MangaDex API is down?")
raise exc
log.error("Mangadex API not reachable. Retrying")
sleep(2)
counter += 1
else:
break
response_body: Dict[str, Dict[str, Any]] = response.json()
# check if manga exists
if response_body["result"] != "ok":
log.error("Manga not found")
raise KeyError
return response_body["data"]
# get the title of the manga (and fix the filename)
def get_manga_title(self) -> str:
log.debug(f"Getting manga title for: {self.manga_uuid}")
attributes = self.manga_data["attributes"]
# try to get the title in requested language
try:
found_title = attributes["title"][self.language]
title = utils.fix_name(found_title)
except KeyError:
log.info("Manga title not found in requested language. Trying alt titles")
else:
log.debug(f"Language={self.language}, Title='{title}'")
return title # type: ignore
# search in alt titles
try:
log.debug(f"Alt titles: {attributes['altTitles']}")
for item in attributes["altTitles"]:
if item.get(self.language):
alt_title_item = item
break
found_title = alt_title_item[self.language]
except (KeyError, UnboundLocalError):
log.warning("Manga title also not found in alt titles. Falling back to english title")
else:
title = utils.fix_name(found_title)
log.debug(f"Language={self.language}, Alt-title='{found_title}'")
return title # type: ignore
found_title = attributes["title"]["en"]
title = utils.fix_name(found_title)
log.debug(f"Language=en, Fallback-title='{title}'")
return title # type: ignore
# check if chapters are available in requested language
def check_chapter_lang(self) -> int:
log.debug(f"Checking for chapters in specified language for: {self.manga_uuid}")
r = requests.get(
f"{self.api_base_url}/manga/{self.manga_uuid}/feed?limit=0&{self.api_additions}",
timeout=10,
)
try:
total_chapters: int = r.json()["total"]
except Exception as exc:
log.error("Error retrieving the chapters list. Did you specify a valid language code?")
raise exc
if total_chapters == 0:
log.error("No chapters available to download in specified language")
raise KeyError
log.debug(f"Total chapters={total_chapters}")
return total_chapters
# get chapter data like name, uuid etc
def get_chapter_data(self) -> Dict[str, ChapterData]:
log.debug(f"Getting chapter data for: {self.manga_uuid}")
api_sorting = "order[chapter]=asc&order[volume]=asc"
# check for chapters in specified lang
total_chapters = self.check_chapter_lang()
chapter_data: Dict[str, ChapterData] = {}
last_volume, last_chapter = ("", "")
offset = 0
while offset < total_chapters: # if more than 500 chapters
r = requests.get(
f"{self.api_base_url}/manga/{self.manga_uuid}/feed?{api_sorting}&limit=500&offset={offset}&{self.api_additions}",
timeout=10,
)
response_body: Dict[str, Any] = r.json()
for chapter in response_body["data"]:
attributes: Dict[str, Any] = chapter["attributes"]
# chapter infos from feed
chapter_num: str = attributes.get("chapter") or ""
chapter_vol: str = attributes.get("volume") or ""
chapter_uuid: str = chapter.get("id") or ""
chapter_name: str = attributes.get("title") or ""
chapter_external: str = attributes.get("externalUrl") or ""
chapter_pages: int = attributes.get("pages") or 0
# check for chapter title and fix it
if chapter_name:
chapter_name = utils.fix_name(chapter_name)
# check if the chapter is external (can't download them)
if chapter_external:
log.debug(f"Chapter is external. Skipping: {chapter_name}")
continue
# check if its duplicate from the last entry
if last_volume == chapter_vol and last_chapter == chapter_num:
continue
# export chapter data as a dict
chapter_index = chapter_num if not self.forcevol else f"{chapter_vol}:{chapter_num}"
chapter_data[chapter_index] = {
"uuid": chapter_uuid,
"volume": chapter_vol,
"chapter": chapter_num,
"name": chapter_name,
"pages": chapter_pages,
}
# add last chapter to duplicate check
last_volume, last_chapter = (chapter_vol, chapter_num)
# increase offset for mangas with more than 500 chapters
offset += 500
return chapter_data
# get images for the chapter (mangadex@home)
def get_chapter_images(self, chapter: str, wait_time: float) -> List[str]:
log.debug(f"Getting chapter images for: {self.manga_uuid}")
athome_url = f"{self.api_base_url}/at-home/server"
chapter_uuid = self.manga_chapter_data[chapter]["uuid"]
# retry up to two times if the api applied rate limits
api_error = False
counter = 1
while counter <= 3:
try:
r = requests.get(f"{athome_url}/{chapter_uuid}", timeout=10)
api_data = r.json()
if api_data["result"] != "ok":
log.error(f"No chapter with the id {chapter_uuid} found")
api_error = True
raise IndexError
if api_data["chapter"]["data"] is None:
log.error(f"No chapter data found for chapter {chapter_uuid}")
api_error = True
raise IndexError
# no error
api_error = False
break
except Exception:
if counter >= 3:
api_error = True
log.error("Retrying in a few seconds")
counter += 1
sleep(wait_time + 2)
# check if result is ok
else:
if api_error:
return []
chapter_hash = api_data["chapter"]["hash"]
chapter_img_data = api_data["chapter"]["data"]
# get list of image urls
image_urls: List[str] = []
for image in chapter_img_data:
image_urls.append(f"{self.img_base_url}/data/{chapter_hash}/{image}")
sleep(wait_time)
return image_urls
# create list of chapters
def create_chapter_list(self) -> List[str]:
log.debug(f"Creating chapter list for: {self.manga_uuid}")
chapter_list: List[str] = []
for data in self.manga_chapter_data.values():
chapter_number: str = data["chapter"]
volume_number: str = data["volume"]
if self.forcevol:
chapter_list.append(f"{volume_number}:{chapter_number}")
else:
chapter_list.append(chapter_number)
return chapter_list
def create_metadata(self, chapter: str) -> ComicInfo:
log.info("Creating metadata from api")
chapter_data = self.manga_chapter_data[chapter]
try:
volume = int(chapter_data["volume"])
except (ValueError, TypeError):
volume = None
metadata: ComicInfo = {
"Volume": volume,
"Number": chapter_data.get("chapter"),
"PageCount": chapter_data.get("pages"),
"Title": chapter_data.get("name"),
"Series": self.manga_title,
"Count": len(self.manga_chapter_data),
"LanguageISO": self.language,
"Summary": self.manga_data["attributes"]["description"].get("en"),
"Genre": self.manga_data["attributes"].get("publicationDemographic"),
"Web": f"https://mangadex.org/title/{self.manga_uuid}",
}
return metadata

View file

@ -1,442 +0,0 @@
import re
import shutil
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
from loguru import logger as log
from mangadlp import downloader, utils
from mangadlp.api.mangadex import Mangadex
from mangadlp.cache import CacheDB
from mangadlp.hooks import run_hook
from mangadlp.metadata import write_metadata
from mangadlp.models import ChapterData
from mangadlp.utils import get_file_format
def match_api(url_uuid: str) -> type:
"""Match the correct api class from a string.
Args:
url_uuid: url/uuid to check
Returns:
The class of the API to use
"""
# apis to check
apis: List[Tuple[str, re.Pattern[str], type]] = [
(
"mangadex.org",
re.compile(
r"(mangadex.org)|([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})"
),
Mangadex,
),
(
"test.org",
re.compile(r"(test.test)"),
type,
),
]
# check url for match
for api_name, api_re, api_cls in apis:
if not api_re.search(url_uuid):
continue
log.info(f"API matched: {api_name}")
return api_cls
# no supported api found
log.error(f"No supported api in link/uuid found: {url_uuid}")
raise ValueError
class MangaDLP:
"""Download Mangas from supported sites.
After initialization, start the script with the function get_manga().
Args:
url_uuid: URL or UUID of the manga
language: Manga language with country codes. "en" --> english
chapters: Chapters to download, "all" for every chapter available
list_chapters: List all available chapters and exit
file_format: Archive format to create. An empty string means don't archive the folder
forcevol: Force naming of volumes. Useful for mangas where chapters reset each volume
download_path: Download path. Defaults to '<script_dir>/downloads'
download_wait: Time to wait for each picture to download in seconds
manga_pre_hook_cmd: Command(s) to before after each manga
manga_post_hook_cmd: Command(s) to run after each manga
chapter_pre_hook_cmd: Command(s) to run before each chapter
chapter_post_hook_cmd: Command(s) to run after each chapter
cache_path: Path to the json cache. If emitted, no cache is used
add_metadata: Flag to toggle creation & inclusion of metadata
"""
def __init__( # noqa
self,
url_uuid: str,
language: str = "en",
chapters: str = "",
list_chapters: bool = False,
file_format: str = "cbz",
name_format: str = "{default}",
name_format_none: str = "",
forcevol: bool = False,
download_path: Union[str, Path] = "downloads",
download_wait: float = 0.5,
manga_pre_hook_cmd: str = "",
manga_post_hook_cmd: str = "",
chapter_pre_hook_cmd: str = "",
chapter_post_hook_cmd: str = "",
cache_path: str = "",
add_metadata: bool = True,
) -> None:
# init parameters
self.url_uuid = url_uuid
self.language = language
self.chapters = chapters
self.list_chapters = list_chapters
self.file_format = file_format
self.name_format = name_format
self.name_format_none = name_format_none
self.forcevol = forcevol
self.download_path: Path = Path(download_path)
self.download_wait = download_wait
self.manga_pre_hook_cmd = manga_pre_hook_cmd
self.manga_post_hook_cmd = manga_post_hook_cmd
self.chapter_pre_hook_cmd = chapter_pre_hook_cmd
self.chapter_post_hook_cmd = chapter_post_hook_cmd
self.cache_path = cache_path
self.add_metadata = add_metadata
self.hook_infos: Dict[str, Any] = {}
# prepare everything
self._prepare()
def _prepare(self) -> None:
# check and set correct file suffix/format
self.file_format = get_file_format(self.file_format)
# start prechecks
self._pre_checks()
# init api
self.api_used = match_api(self.url_uuid)
try:
log.debug("Initializing api")
self.api = self.api_used(self.url_uuid, self.language, self.forcevol)
except Exception as exc:
log.error("Can't initialize api. Exiting")
raise exc
# get manga title and uuid
self.manga_uuid = self.api.manga_uuid
self.manga_title = self.api.manga_title
# get chapter list
self.manga_chapter_list = self.api.chapter_list
self.manga_total_chapters = len(self.manga_chapter_list)
self.manga_path = self.download_path / self.manga_title
def _pre_checks(self) -> None:
# prechecks userinput/options
# no url and no readin list given
if not self.url_uuid:
log.error('You need to specify a manga url/uuid with "-u" or a list with "--read"')
raise ValueError
# checks if --list is not used
if not self.list_chapters:
if not self.chapters:
# no chapters to download were given
log.error(
'You need to specify one or more chapters to download. To see all chapters use "--list"'
)
raise ValueError
# if forcevol is used, but didn't specify a volume in the chapters selected
if self.forcevol and ":" not in self.chapters:
log.error("You need to specify the volume if you use --forcevol")
raise ValueError
# if forcevol is not used, but a volume is specified
if not self.forcevol and ":" in self.chapters:
log.error("Don't specify the volume without --forcevol")
raise ValueError
# once called per manga
def get_manga(self) -> None: # noqa
print_divider = "========================================="
# show infos
log.info(f"{print_divider}")
log.info(f"Manga Name: {self.manga_title}")
log.info(f"Manga UUID: {self.manga_uuid}")
log.info(f"Total chapters: {self.manga_total_chapters}")
# list chapters if list_chapters is true
if self.list_chapters:
log.info(f"Available Chapters: {', '.join(self.manga_chapter_list)}")
log.info(f"{print_divider}\n")
return
# check chapters to download if not all
if self.chapters.lower() == "all":
chapters_to_download = self.manga_chapter_list
else:
chapters_to_download = utils.get_chapter_list(self.chapters, self.manga_chapter_list)
# show chapters to download
log.info(f"Chapters selected: {', '.join(chapters_to_download)}")
log.info(f"{print_divider}")
# create manga folder
self.manga_path.mkdir(parents=True, exist_ok=True)
# prepare cache if specified
if self.cache_path:
cache = CacheDB(self.cache_path, self.manga_uuid, self.language, self.manga_title)
cached_chapters = cache.db_uuid_chapters
log.info(f"Cached chapters: {cached_chapters}")
# create dict with all variables for the hooks
self.hook_infos.update(
{
"api": self.api.api_name,
"manga_url_uuid": self.url_uuid,
"manga_uuid": self.manga_uuid,
"manga_title": self.manga_title,
"language": self.language,
"total_chapters": self.manga_total_chapters,
"chapters_to_download": chapters_to_download,
"file_format": self.file_format,
"forcevol": self.forcevol,
"download_path": str(self.download_path),
"manga_path": self.manga_path,
}
)
# start manga pre hook
run_hook(
command=self.manga_pre_hook_cmd,
hook_type="manga_pre",
status="starting",
**self.hook_infos,
)
# get chapters
skipped_chapters: List[Any] = []
error_chapters: List[Any] = []
for chapter in chapters_to_download:
if self.cache_path and chapter in cached_chapters:
log.info(f"Chapter '{chapter}' is in cache. Skipping download")
continue
# download chapter
try:
chapter_path = self.get_chapter(chapter)
except KeyboardInterrupt as exc:
raise exc
except FileExistsError:
# skipping chapter download as its already available
skipped_chapters.append(chapter)
# update cache
if self.cache_path:
cache.add_chapter(chapter)
continue
except Exception:
# skip download/packing due to an error
error_chapters.append(chapter)
continue
# add metadata
if self.add_metadata:
try:
metadata = self.api.create_metadata(chapter)
write_metadata(
chapter_path,
{"Format": self.file_format[1:], **metadata},
)
except Exception as exc:
log.warning(f"Can't write metadata for chapter '{chapter}'. Reason={exc}")
# pack downloaded folder
if self.file_format:
try:
self.archive_chapter(chapter_path)
except Exception:
error_chapters.append(chapter)
continue
# done with chapter
log.info(f"Done with chapter '{chapter}'")
# update cache
if self.cache_path:
cache.add_chapter(chapter)
# start chapter post hook
run_hook(
command=self.chapter_post_hook_cmd,
hook_type="chapter_post",
status="successful",
**self.hook_infos,
)
# done with manga
log.info(f"{print_divider}")
log.info(f"Done with manga: {self.manga_title}")
# filter skipped list
skipped_chapters = list(filter(None, skipped_chapters))
if len(skipped_chapters) >= 1:
log.info(f"Skipped chapters: {', '.join(skipped_chapters)}")
# filter error list
error_chapters = list(filter(None, error_chapters))
if len(error_chapters) >= 1:
log.info(f"Chapters with errors: {', '.join(error_chapters)}")
# start manga post hook
run_hook(
command=self.manga_post_hook_cmd,
hook_type="manga_post",
status="successful",
**self.hook_infos,
)
log.info(f"{print_divider}\n")
# once called per chapter
def get_chapter(self, chapter: str) -> Path:
# get chapter infos
chapter_infos: ChapterData = self.api.manga_chapter_data[chapter]
log.debug(f"Chapter infos: {chapter_infos}")
# get image urls for chapter
try:
chapter_image_urls = self.api.get_chapter_images(chapter, self.download_wait)
except KeyboardInterrupt as exc:
log.critical("Keyboard interrupt. Stopping")
raise exc
# check if the image urls are empty. if yes skip this chapter (for mass downloads)
if not chapter_image_urls:
log.error(
f"No images: Skipping Vol. {chapter_infos['volume']} Ch.{chapter_infos['chapter']}"
)
run_hook(
command=self.chapter_pre_hook_cmd,
hook_type="chapter_pre",
status="skipped",
reason="No images",
**self.hook_infos,
)
# error
raise SystemError
# get filename for chapter (without suffix)
chapter_filename = utils.get_filename(
self.manga_title,
chapter_infos["name"],
chapter_infos["volume"],
chapter,
self.forcevol,
self.name_format,
self.name_format_none,
)
log.debug(f"Filename: '{chapter_filename}'")
# set download path for chapter (image folder)
chapter_path: Path = self.manga_path / chapter_filename
# set archive path with file format
chapter_archive_path = Path(f"{chapter_path}{self.file_format}")
# check if chapter already exists
# check for folder, if file format is an empty string
if chapter_archive_path.exists():
log.info(f"'{chapter_archive_path}' already exists. Skipping")
run_hook(
command=self.chapter_pre_hook_cmd,
hook_type="chapter_pre",
status="skipped",
reason="Existing",
**self.hook_infos,
)
# skipped
raise FileExistsError
# create chapter folder (skips it if it already exists)
chapter_path.mkdir(parents=True, exist_ok=True)
# verbose log
log.debug(f"Chapter UUID: {chapter_infos['uuid']}")
log.debug(f"File path: '{chapter_archive_path}'")
log.debug(f"Image URLS:\n{chapter_image_urls}")
# create dict with all variables for the hooks
self.hook_infos.update(
{
"chapter_filename": chapter_filename,
"chapter_path": chapter_path,
"chapter_archive_path": chapter_archive_path,
"chapter_uuid": chapter_infos["uuid"],
"chapter_volume": chapter_infos["volume"],
"chapter_number": chapter_infos["chapter"],
"chapter_name": chapter_infos["name"],
}
)
# start chapter pre hook
run_hook(
command=self.chapter_pre_hook_cmd,
hook_type="chapter_pre",
status="starting",
**self.hook_infos,
)
# log
log.info(f"Downloading: '{chapter_filename}'")
# download images
try:
downloader.download_chapter(chapter_image_urls, chapter_path, self.download_wait)
except KeyboardInterrupt as exc:
log.critical("Keyboard interrupt. Stopping")
raise exc
except Exception as exc:
log.error(f"Cant download: '{chapter_filename}'. Skipping")
# run chapter post hook
run_hook(
command=self.chapter_post_hook_cmd,
hook_type="chapter_post",
status="starting",
reason="Download error",
**self.hook_infos,
)
# chapter error
raise exc
# Done with chapter
log.info(f"Successfully downloaded: '{chapter_filename}'")
# ok
return chapter_path
# create an archive of the chapter if needed
def archive_chapter(self, chapter_path: Path) -> None:
log.info(f"Creating archive '{chapter_path}{self.file_format}'")
try:
# check if image folder is existing
if not chapter_path.exists():
log.error(f"Image folder: {chapter_path} does not exist")
raise OSError
if self.file_format == ".pdf":
utils.make_pdf(chapter_path)
else:
utils.make_archive(chapter_path, self.file_format)
except Exception as exc:
log.error("Archive error. Skipping chapter")
raise exc
# remove image folder
shutil.rmtree(chapter_path)

View file

@ -1,85 +0,0 @@
import json
from pathlib import Path
from typing import List, Union
from loguru import logger as log
from mangadlp.models import CacheData, CacheKeyData
class CacheDB:
def __init__(
self,
db_path: Union[str, Path],
manga_uuid: str,
manga_lang: str,
manga_name: str,
) -> None:
self.db_path = Path(db_path)
self.uuid = manga_uuid
self.lang = manga_lang
self.name = manga_name
self.db_key = f"{manga_uuid}__{manga_lang}"
self._prepare_db()
self.db_data = self._read_db()
# create db key entry if not found
if not self.db_data.get(self.db_key):
self.db_data[self.db_key] = {}
self.db_uuid_data: CacheKeyData = self.db_data[self.db_key]
if not self.db_uuid_data.get("name"):
self.db_uuid_data.update({"name": self.name})
self._write_db()
self.db_uuid_chapters: List[str] = self.db_uuid_data.get("chapters") or []
def _prepare_db(self) -> None:
if self.db_path.exists():
return
# create empty cache
try:
self.db_path.touch()
self.db_path.write_text(json.dumps({}), encoding="utf8")
except Exception as exc:
log.error("Can't create db-file")
raise exc
def _read_db(self) -> CacheData:
log.info(f"Reading cache-db: {self.db_path}")
try:
db_txt = self.db_path.read_text(encoding="utf8")
db_dict: CacheData = json.loads(db_txt)
except Exception as exc:
log.error("Can't load cache-db")
raise exc
return db_dict
def _write_db(self) -> None:
db_dump = json.dumps(self.db_data, indent=4, sort_keys=True)
self.db_path.write_text(db_dump, encoding="utf8")
def add_chapter(self, chapter: str) -> None:
log.info(f"Adding chapter to cache-db: {chapter}")
self.db_uuid_chapters.append(chapter)
# dedup entries
updated_chapters = list({*self.db_uuid_chapters})
sorted_chapters = sort_chapters(updated_chapters)
try:
self.db_data[self.db_key]["chapters"] = sorted_chapters
self._write_db()
except Exception as exc:
log.error("Can't write cache-db")
raise exc
def sort_chapters(chapters: List[str]) -> List[str]:
try:
sorted_list = sorted(chapters, key=float)
except Exception:
log.debug("Can't sort cache by float, using default sorting")
sorted_list = sorted(chapters)
return sorted_list

View file

@ -1,265 +0,0 @@
import sys
from pathlib import Path
from typing import Any, List
import click
from click_option_group import (
MutuallyExclusiveOptionGroup,
RequiredMutuallyExclusiveOptionGroup,
optgroup,
)
from loguru import logger as log
from mangadlp import app
from mangadlp.__about__ import __version__
from mangadlp.logger import prepare_logger
# read in the list of links from a file
def readin_list(_ctx: click.Context, _param: str, value: str) -> List[str]:
if not value:
return []
list_file = Path(value)
click.echo(f"Reading in file '{list_file}'")
try:
url_str = list_file.read_text(encoding="utf-8")
url_list = url_str.splitlines()
except Exception as exc:
msg = f"Reading in file '{list_file}'"
raise click.BadParameter(msg) from exc
# filter empty lines and remove them
filtered_list = list(filter(len, url_list))
click.echo(f"Mangas from list: {filtered_list}")
return filtered_list
@click.command(context_settings={"max_content_width": 150})
@click.help_option()
@click.version_option(version=__version__, package_name="manga-dlp")
# manga selection
@optgroup.group("source", cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option(
"-u",
"--url",
"--uuid",
"url_uuid",
type=str,
default=None,
show_default=True,
help="URL or UUID of the manga",
)
@optgroup.option(
"--read",
"read_mangas",
is_eager=True,
callback=readin_list,
type=click.Path(exists=True, dir_okay=False, path_type=str),
default=None,
show_default=True,
help="Path of file with manga links to download. One per line",
)
# logging options
@optgroup.group("verbosity", cls=MutuallyExclusiveOptionGroup)
@optgroup.option(
"--loglevel",
"verbosity",
type=int,
default=None,
show_default=True,
help="Custom log level",
)
@optgroup.option(
"--warn",
"verbosity",
flag_value=30,
default=None,
show_default=True,
help="Only log warnings and higher",
)
@optgroup.option(
"--debug",
"verbosity",
flag_value=10,
default=None,
show_default=True,
help="Debug logging. Log EVERYTHING",
)
# other options
@click.option(
"-c",
"--chapters",
"chapters",
type=str,
default=None,
required=False,
show_default=True,
help="Chapters to download",
)
@click.option(
"-p",
"--path",
"download_path",
type=click.Path(exists=False, writable=True, path_type=Path),
default="downloads",
required=False,
show_default=True,
help="Download path",
)
@click.option(
"-l",
"--language",
"language",
type=str,
default="en",
required=False,
show_default=True,
help="Manga language",
)
@click.option(
"--list",
"list_chapters",
is_flag=True,
default=False,
required=False,
show_default=True,
help="List all available chapters",
)
@click.option(
"--format",
"file_format",
multiple=False,
type=click.Choice(["cbz", "cbr", "zip", "pdf", ""], case_sensitive=False),
default="cbz",
required=False,
show_default=True,
help="Archive format to create. An empty string means don't archive the folder",
)
@click.option(
"--name-format",
"name_format",
type=str,
default="{default}",
required=False,
show_default=True,
help="Naming format to use when saving chapters. See docs for more infos",
)
@click.option(
"--name-format-none",
"name_format_none",
type=str,
default="",
required=False,
show_default=True,
help="String to use when the variable of the custom name format is empty",
)
@click.option(
"--forcevol",
"forcevol",
is_flag=True,
default=False,
required=False,
show_default=True,
help="Force naming of volumes. For mangas where chapters reset each volume",
)
@click.option(
"--wait",
"download_wait",
type=float,
default=0.5,
required=False,
show_default=True,
help="Time to wait for each picture to download in seconds(float)",
)
# hook options
@click.option(
"--hook-manga-pre",
"manga_pre_hook_cmd",
type=str,
default=None,
required=False,
show_default=True,
help="Commands to execute before the manga download starts",
)
@click.option(
"--hook-manga-post",
"manga_post_hook_cmd",
type=str,
default=None,
required=False,
show_default=True,
help="Commands to execute after the manga download finished",
)
@click.option(
"--hook-chapter-pre",
"chapter_pre_hook_cmd",
type=str,
default=None,
required=False,
show_default=True,
help="Commands to execute before the chapter download starts",
)
@click.option(
"--hook-chapter-post",
"chapter_post_hook_cmd",
type=str,
default=None,
required=False,
show_default=True,
help="Commands to execute after the chapter download finished",
)
@click.option(
"--cache-path",
"cache_path",
type=click.Path(exists=False, writable=True, path_type=str),
default=None,
required=False,
show_default=True,
help="Where to store the cache-db. If no path is given, cache is disabled",
)
@click.option(
"--add-metadata/--no-metadata",
"add_metadata",
is_flag=True,
default=True,
required=False,
show_default=True,
help="Enable/disable creation of metadata via ComicInfo.xml",
)
@click.pass_context
def main(ctx: click.Context, **kwargs: Any) -> None:
"""Script to download mangas from various sites."""
url_uuid: str = kwargs.pop("url_uuid")
read_mangas: List[str] = kwargs.pop("read_mangas")
verbosity: int = kwargs.pop("verbosity")
# set log level to INFO if not set
if not verbosity:
verbosity = 20
# set loglevel and log format
prepare_logger(verbosity)
# list all params
log.debug(ctx.params)
# all request mangas
requested_mangas = [url_uuid] if url_uuid else read_mangas
for manga in requested_mangas:
try:
mdlp = app.MangaDLP(url_uuid=manga, **kwargs)
mdlp.get_manga()
except (KeyboardInterrupt, Exception) as exc:
# if only a single manga is requested and had an error, then exit
if len(requested_mangas) == 1:
log.error(f"Error with manga: {manga}")
sys.exit(1)
# else continue with the other ones
log.error(f"Skipping: {manga}. Reason={exc}")
if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter

View file

@ -1,43 +0,0 @@
import os
import subprocess
from typing import Any
from loguru import logger as log
def run_hook(command: str, hook_type: str, **kwargs: Any) -> int:
"""Run a command.
Run a command with subprocess.run and add kwargs to the environment.
Args:
command (str): command to run
hook_type (str): type of the hook
kwargs: key value pairs of env vars to set
Returns:
exit_code (int): exit code of command
"""
# check if hook commands are empty
if not command or command == "None":
log.debug(f"Hook '{hook_type}' empty. Not running")
return 2
command_list = command.split(" ")
# setting env vars
for key, value in kwargs.items():
os.environ[f"MDLP_{key.upper()}"] = str(value)
# running command
log.info(f"Hook '{hook_type}' - running command: '{command}'")
proc = subprocess.run(command_list, check=False, timeout=15, encoding="utf8") # noqa
exit_code = proc.returncode
if exit_code == 0:
log.debug("Hook returned status code 0. All good")
else:
log.warning(f"Hook returned status code {exit_code}. Possible error")
# return exit code of command
return exit_code

View file

@ -1,40 +0,0 @@
import logging
import sys
from typing import Any, Dict
from loguru import logger
LOGURU_FMT = "{time:%Y-%m-%dT%H:%M:%S%z} | <level>[{level: <7}]</level> [{name: <10}] [{function: <20}]: {message}"
# from loguru docs
class InterceptHandler(logging.Handler):
"""Intercept python logging messages and log them via loguru.logger."""
def emit(self, record: Any) -> None:
# Get corresponding Loguru level if it exists
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# Find caller from where originated the logged message
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__:
frame = frame.f_back # type: ignore
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
# init logger with format and log level
def prepare_logger(loglevel: int = 20) -> None:
stdout_handler: Dict[str, Any] = {
"sink": sys.stdout,
"level": loglevel,
"format": LOGURU_FMT,
}
logging.basicConfig(handlers=[InterceptHandler()], level=loglevel)
logger.configure(handlers=[stdout_handler])

View file

@ -1,119 +0,0 @@
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
import xmltodict
from loguru import logger as log
from mangadlp.models import ComicInfo
METADATA_FILENAME = "ComicInfo.xml"
METADATA_TEMPLATE = Path("mangadlp/metadata/ComicInfo_v2.0.xml")
# define metadata types, defaults and valid values. an empty list means no value check
# {key: (type, default value, valid values)}
METADATA_TYPES: Dict[str, Tuple[Any, Union[str, int, None], List[Union[str, int, None]]]] = {
"Title": (str, None, []),
"Series": (str, None, []),
"Number": (str, None, []),
"Count": (int, None, []),
"Volume": (int, None, []),
"AlternateSeries": (str, None, []),
"AlternateNumber": (str, None, []),
"AlternateCount": (int, None, []),
"Summary": (str, None, []),
"Notes": (str, "Downloaded with https://github.com/olofvndrhr/manga-dlp", []),
"Year": (int, None, []),
"Month": (int, None, []),
"Day": (int, None, []),
"Writer": (str, None, []),
"Colorist": (str, None, []),
"Publisher": (str, None, []),
"Genre": (str, None, []),
"Web": (str, None, []),
"PageCount": (int, None, []),
"LanguageISO": (str, None, []),
"Format": (str, None, []),
"BlackAndWhite": (str, None, ["Yes", "No", "Unknown"]),
"Manga": (str, "Yes", ["Yes", "No", "Unknown", "YesAndRightToLeft"]),
"ScanInformation": (str, None, []),
"SeriesGroup": (str, None, []),
"AgeRating": (
str,
None,
[
"Unknown",
"Adults Only 18+",
"Early Childhood",
"Everyone",
"Everyone 10+",
"G",
"Kids to Adults",
"M",
"MA15+",
"Mature 17+",
"PG",
"R18+",
"Rating Pending",
"Teen",
"X18+",
],
),
"CommunityRating": (int, None, [1, 2, 3, 4, 5]),
}
def validate_metadata(metadata_in: ComicInfo) -> Dict[str, ComicInfo]:
log.info("Validating metadata")
metadata_valid: Dict[str, ComicInfo] = {"ComicInfo": {}}
for key, value in METADATA_TYPES.items():
metadata_type, metadata_default, metadata_validation = value
# add default value if present
if metadata_default:
log.debug(f"Setting default value for Key:{key} -> value={metadata_default}")
metadata_valid["ComicInfo"][key] = metadata_default
# check if metadata key is available
try:
md_to_check: Union[str, int, None] = metadata_in[key]
except KeyError:
continue
# check if provided metadata item is empty
if not md_to_check:
continue
# check if metadata type is correct
log.debug(f"Key:{key} -> value={type(md_to_check)} -> check={metadata_type}")
if not isinstance(md_to_check, metadata_type):
log.warning(f"Metadata has wrong type: {key}:{metadata_type} -> {md_to_check}")
continue
# check if metadata is valid
log.debug(f"Key:{key} -> value={md_to_check} -> valid={metadata_validation}")
if (len(metadata_validation) > 0) and (md_to_check not in metadata_validation):
log.warning(f"Metadata is invalid: {key}:{metadata_validation} -> {md_to_check}")
continue
log.debug(f"Updating metadata: '{key}' = '{md_to_check}'")
metadata_valid["ComicInfo"][key] = md_to_check
return metadata_valid
def write_metadata(chapter_path: Path, metadata: ComicInfo) -> None:
if metadata["Format"] == "pdf":
log.warning("Can't add metadata for pdf format. Skipping")
return
metadata_file = chapter_path / METADATA_FILENAME
log.debug(f"Metadata items: {metadata}")
metadata_valid = validate_metadata(metadata)
log.info(f"Writing metadata to: '{metadata_file}'")
metadata_export = xmltodict.unparse(
metadata_valid, pretty=True, indent=" " * 4, short_empty_elements=True
)
metadata_file.touch(exist_ok=True)
metadata_file.write_text(metadata_export, encoding="utf8")

View file

@ -1,123 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="ComicInfo" nillable="true" type="ComicInfo" />
<xs:complexType name="ComicInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Title" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Series" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Number" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Count" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Volume" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateSeries" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateNumber" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="AlternateCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Summary" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Notes" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Year" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Month" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Day" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Writer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Penciller" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Inker" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Colorist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Letterer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="CoverArtist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Editor" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Publisher" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Imprint" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Genre" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Web" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="0" name="PageCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="LanguageISO" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Format" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="BlackAndWhite" type="YesNo" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="Manga" type="Manga" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Characters" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Teams" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Locations" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="ScanInformation" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="StoryArc" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="SeriesGroup" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="AgeRating" type="AgeRating" />
<xs:element minOccurs="0" maxOccurs="1" name="Pages" type="ArrayOfComicPageInfo" />
<xs:element minOccurs="0" maxOccurs="1" name="CommunityRating" type="Rating" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="MainCharacterOrTeam" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Review" type="xs:string" />
</xs:sequence>
</xs:complexType>
<xs:simpleType name="YesNo">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="No" />
<xs:enumeration value="Yes" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Manga">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="No" />
<xs:enumeration value="Yes" />
<xs:enumeration value="YesAndRightToLeft" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Rating">
<xs:restriction base="xs:decimal">
<xs:minInclusive value="0"/>
<xs:maxInclusive value="5"/>
<xs:fractionDigits value="2"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AgeRating">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="Adults Only 18+" />
<xs:enumeration value="Early Childhood" />
<xs:enumeration value="Everyone" />
<xs:enumeration value="Everyone 10+" />
<xs:enumeration value="G" />
<xs:enumeration value="Kids to Adults" />
<xs:enumeration value="M" />
<xs:enumeration value="MA15+" />
<xs:enumeration value="Mature 17+" />
<xs:enumeration value="PG" />
<xs:enumeration value="R18+" />
<xs:enumeration value="Rating Pending" />
<xs:enumeration value="Teen" />
<xs:enumeration value="X18+" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="ArrayOfComicPageInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" name="Page" nillable="true" type="ComicPageInfo" />
</xs:sequence>
</xs:complexType>
<xs:complexType name="ComicPageInfo">
<xs:attribute name="Image" type="xs:int" use="required" />
<xs:attribute default="Story" name="Type" type="ComicPageType" />
<xs:attribute default="false" name="DoublePage" type="xs:boolean" />
<xs:attribute default="0" name="ImageSize" type="xs:long" />
<xs:attribute default="" name="Key" type="xs:string" />
<xs:attribute default="" name="Bookmark" type="xs:string" />
<xs:attribute default="-1" name="ImageWidth" type="xs:int" />
<xs:attribute default="-1" name="ImageHeight" type="xs:int" />
</xs:complexType>
<xs:simpleType name="ComicPageType">
<xs:list>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="FrontCover" />
<xs:enumeration value="InnerCover" />
<xs:enumeration value="Roundup" />
<xs:enumeration value="Story" />
<xs:enumeration value="Advertisement" />
<xs:enumeration value="Editorial" />
<xs:enumeration value="Letters" />
<xs:enumeration value="Preview" />
<xs:enumeration value="BackCover" />
<xs:enumeration value="Other" />
<xs:enumeration value="Deleted" />
</xs:restriction>
</xs:simpleType>
</xs:list>
</xs:simpleType>
</xs:schema>

View file

@ -1,59 +0,0 @@
from typing import List, Optional, TypedDict
class ComicInfo(TypedDict, total=False):
"""ComicInfo.xml basic types.
Validation is done via metadata.validate_metadata()
All valid types and values are specified in metadata.METADATA_TYPES
"""
Title: Optional[str]
Series: Optional[str]
Number: Optional[str]
Count: Optional[int]
Volume: Optional[int]
AlternateSeries: Optional[str]
AlternateNumber: Optional[str]
AlternateCount: Optional[int]
Summary: Optional[str]
Notes: Optional[str]
Year: Optional[int]
Month: Optional[int]
Day: Optional[int]
Writer: Optional[str]
Colorist: Optional[str]
Publisher: Optional[str]
Genre: Optional[str]
Web: Optional[str]
PageCount: Optional[int]
LanguageISO: Optional[str]
Format: Optional[str]
BlackAndWhite: Optional[str]
Manga: Optional[str]
ScanInformation: Optional[str]
SeriesGroup: Optional[str]
AgeRating: Optional[str]
CommunityRating: Optional[int]
class ChapterData(TypedDict):
"""Basic chapter-data types.
All values have to be provided.
"""
uuid: str
volume: str
chapter: str
name: str
pages: int
class CacheKeyData(TypedDict):
chapters: List[str]
name: str
class CacheData(TypedDict):
__root__: CacheKeyData

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ComicInfo>
<Title>title1</Title>
<Series>series1</Series>
<Number>2</Number>
<Count>10</Count>
<Volume>1</Volume>
<Summary>summary1</Summary>
<Notes>Downloaded with https://github.com/olofvndrhr/manga-dlp</Notes>
<Genre>genre1</Genre>
<Web>https://mangadex.org</Web>
<PageCount>99</PageCount>
<LanguageISO>en</LanguageISO>
<Format>cbz</Format>
<Manga>Yes</Manga>
</ComicInfo>

View file

@ -1,14 +1,12 @@
import pytest import pytest
import mangadlp.app as app
from mangadlp.api.mangadex import Mangadex from mangadlp.api.mangadex import Mangadex
from mangadlp.app import MangaDLP
def test_check_api_mangadex(): def test_check_api_mangadex():
url = ( url = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu" test = app.MangaDLP(url_uuid=url, list_chapters=True, download_wait=2)
)
test = MangaDLP(url_uuid=url, list_chapters=True, download_wait=2)
assert test.api_used == Mangadex assert test.api_used == Mangadex
@ -16,5 +14,5 @@ def test_check_api_mangadex():
def test_check_api_none(): def test_check_api_none():
url = "https://abc.defghjk/title/abc/def" url = "https://abc.defghjk/title/abc/def"
with pytest.raises(ValueError) as e: with pytest.raises(ValueError) as e:
MangaDLP(url_uuid=url, list_chapters=True, download_wait=2) app.MangaDLP(url_uuid=url, list_chapters=True, download_wait=2)
assert e.type == ValueError assert e.type == ValueError

View file

@ -3,7 +3,8 @@ from pathlib import Path
import pytest import pytest
from mangadlp import app, utils import mangadlp.app as app
import mangadlp.utils as utils
def test_make_archive_true(): def test_make_archive_true():
@ -26,9 +27,9 @@ def test_make_archive_false():
archive_path = Path("tests/test_dir2.cbz") archive_path = Path("tests/test_dir2.cbz")
img_path = Path("tests/test_dir2") img_path = Path("tests/test_dir2")
file_format = "cbz" file_format = "cbz"
with pytest.raises(Exception) as e: with pytest.raises(IOError) as e:
utils.make_archive(img_path, file_format) utils.make_archive(img_path, file_format)
assert e.type == FileNotFoundError assert e.type == IOError
assert not archive_path.exists() assert not archive_path.exists()
# cleanup # cleanup
Path("tests/test_dir2.zip").unlink() Path("tests/test_dir2.zip").unlink()
@ -56,6 +57,7 @@ def test_chapter_list_full():
forcevol=True, forcevol=True,
download_path="tests", download_path="tests",
download_wait=2, download_wait=2,
verbosity=10,
) )
chap_list = utils.get_chapter_list("1:1,1:2,1:4-1:7,2:", mdlp.manga_chapter_list) chap_list = utils.get_chapter_list("1:1,1:2,1:4-1:7,2:", mdlp.manga_chapter_list)
assert chap_list == [ assert chap_list == [
@ -94,207 +96,55 @@ def test_fix_name():
def test_get_filename_forcevol(): def test_get_filename_forcevol():
manga_name = "The test manga"
chapter_name = "The holy test Chapter" chapter_name = "The holy test Chapter"
chapter_vol = "2" chapter_vol = "2"
chapter_num = "44" chapter_num = "44"
forcevol = True forcevol = True
name_format = "{default}"
name_format_none = ""
filename = "Vol. 2 Ch. 44 - The holy test Chapter" filename = "Vol. 2 Ch. 44 - The holy test Chapter"
assert ( assert (
utils.get_filename( utils.get_filename(chapter_name, chapter_vol, chapter_num, forcevol) == filename
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
) )
def test_get_filename_forcevol_noname(): def test_get_filename_forcevol_noname():
manga_name = "The test manga"
chapter_name = "" chapter_name = ""
chapter_vol = "2" chapter_vol = "2"
chapter_num = "44" chapter_num = "44"
forcevol = True forcevol = True
name_format = "{default}"
name_format_none = ""
filename = "Vol. 2 Ch. 44" filename = "Vol. 2 Ch. 44"
assert ( assert (
utils.get_filename( utils.get_filename(chapter_name, chapter_vol, chapter_num, forcevol) == filename
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
)
def test_get_filename_novol():
manga_name = "The test manga"
chapter_name = ""
chapter_vol = ""
chapter_num = "1"
forcevol = True
name_format = "{default}"
name_format_none = ""
filename = "Vol. 0 Ch. 1"
assert (
utils.get_filename(
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
) )
def test_get_filename(): def test_get_filename():
manga_name = "The test manga"
chapter_name = "The holy test Chapter" chapter_name = "The holy test Chapter"
chapter_vol = "2" chapter_vol = "2"
chapter_num = "44" chapter_num = "44"
forcevol = False forcevol = False
name_format = "{default}"
name_format_none = ""
filename = "Ch. 44 - The holy test Chapter" filename = "Ch. 44 - The holy test Chapter"
assert ( assert (
utils.get_filename( utils.get_filename(chapter_name, chapter_vol, chapter_num, forcevol) == filename
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
) )
def test_get_filename_oneshot(): def test_get_filename_oneshot():
manga_name = "The test manga"
chapter_name = "Oneshot" chapter_name = "Oneshot"
chapter_vol = "" chapter_vol = ""
chapter_num = "" chapter_num = ""
forcevol = False forcevol = False
name_format = "{default}"
name_format_none = ""
filename = "Oneshot" filename = "Oneshot"
assert ( assert (
utils.get_filename( utils.get_filename(chapter_name, chapter_vol, chapter_num, forcevol) == filename
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
) )
def test_get_filename_noname(): def test_get_filename_noname():
manga_name = "The test manga"
chapter_name = "" chapter_name = ""
chapter_vol = "1" chapter_vol = "1"
chapter_num = "1" chapter_num = "1"
forcevol = False forcevol = False
name_format = "{default}"
name_format_none = ""
filename = "Ch. 1" filename = "Ch. 1"
assert ( assert (
utils.get_filename( utils.get_filename(chapter_name, chapter_vol, chapter_num, forcevol) == filename
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
)
def test_get_filename_custom_format():
manga_name = "The test manga"
chapter_name = "Test"
chapter_vol = "1"
chapter_num = "1"
forcevol = False
name_format = "{manga_title}-{chapter_name}-{chapter_num}-{chapter_vol}"
name_format_none = ""
filename = "The test manga-Test-1-1"
assert (
utils.get_filename(
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
)
def test_get_filename_custom_format_err():
manga_name = "The test manga"
chapter_name = "Test"
chapter_vol = "1"
chapter_num = "1"
forcevol = False
name_format = "{chapter_test}-{chapter_num}-{chapter_vol}"
name_format_none = ""
filename = "Ch. 1 - Test"
assert (
utils.get_filename(
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
)
def test_get_filename_custom_format_none():
manga_name = "The test manga"
chapter_name = ""
chapter_vol = "1"
chapter_num = ""
forcevol = False
name_format = "{chapter_name}-{chapter_num}-{chapter_vol}"
name_format_none = "ABC"
filename = "ABC-ABC-1"
assert (
utils.get_filename(
manga_name,
chapter_name,
chapter_vol,
chapter_num,
forcevol,
name_format,
name_format_none,
)
== filename
) )

View file

@ -1,12 +1,10 @@
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import List
import pytest import pytest
import requests import requests
from pytest import MonkeyPatch
from mangadlp import downloader import mangadlp.downloader as downloader
def test_downloader(): def test_downloader():
@ -19,7 +17,7 @@ def test_downloader():
] ]
chapter_path = Path("tests/test_folder1") chapter_path = Path("tests/test_folder1")
chapter_path.mkdir(parents=True, exist_ok=True) chapter_path.mkdir(parents=True, exist_ok=True)
images: List[str] = [] images = []
downloader.download_chapter(urls, str(chapter_path), 2) downloader.download_chapter(urls, str(chapter_path), 2)
for file in chapter_path.iterdir(): for file in chapter_path.iterdir():
images.append(file.name) images.append(file.name)
@ -30,7 +28,7 @@ def test_downloader():
shutil.rmtree(chapter_path, ignore_errors=True) shutil.rmtree(chapter_path, ignore_errors=True)
def test_downloader_fail(monkeypatch: MonkeyPatch): def test_downloader_fail(monkeypatch):
images = [ images = [
"https://uploads.mangadex.org/data/f1117c5e7aff315bc3429a8791c89d63/A1-c111d78b798f1dda1879334a3478f7ae4503578e8adf1af0fcc4e14d2a396ad4.png", "https://uploads.mangadex.org/data/f1117c5e7aff315bc3429a8791c89d63/A1-c111d78b798f1dda1879334a3478f7ae4503578e8adf1af0fcc4e14d2a396ad4.png",
"https://uploads.mangadex.org/data/f1117c5e7aff315bc3429a8791c89d63/A2-717ec3c83e8e05ed7b505941431a417ebfed6a005f78b89650efd3b088b951ec.png", "https://uploads.mangadex.org/data/f1117c5e7aff315bc3429a8791c89d63/A2-717ec3c83e8e05ed7b505941431a417ebfed6a005f78b89650efd3b088b951ec.png",
@ -44,9 +42,9 @@ def test_downloader_fail(monkeypatch: MonkeyPatch):
chapter_path = Path("tests/test_folder1") chapter_path = Path("tests/test_folder1")
chapter_path.mkdir(parents=True, exist_ok=True) chapter_path.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(requests, "get", fail_url) monkeypatch.setattr(requests, "get", fail_url)
with pytest.raises(TypeError) as e: with pytest.raises(ConnectionError) as e:
downloader.download_chapter(images, str(chapter_path), 2) downloader.download_chapter(images, str(chapter_path), 2)
assert e.type == TypeError assert e.type == ConnectionError
# cleanup # cleanup
shutil.rmtree(chapter_path, ignore_errors=True) shutil.rmtree(chapter_path, ignore_errors=True)

View file

@ -1,6 +1,9 @@
import os import os
from pathlib import Path
import mangadlp.cli as mdlpinput import pytest
import mangadlp.input as mdlpinput
def test_read_and_url(): def test_read_and_url():
@ -16,13 +19,13 @@ def test_read_and_url():
def test_no_read_and_url(): def test_no_read_and_url():
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
link_file = "tests/testfile.txt"
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
command_args = ( command_args = f"-l {language} -c {chapters} --path {download_path} --format {file_format} --debug"
f"-l {language} -c {chapters} --path {download_path} --format {file_format} --debug"
)
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
assert os.system(f"python3 {script_path} {command_args}") != 0 assert os.system(f"python3 {script_path} {command_args}") != 0
@ -30,11 +33,10 @@ def test_no_read_and_url():
def test_no_chaps(): def test_no_chaps():
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie" url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
language = "en" language = "en"
chapters = ""
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
command_args = ( command_args = f"-u {url_uuid} -l {language} --path {download_path} --format {file_format} --debug"
f"-u {url_uuid} -l {language} --path {download_path} --format {file_format} --debug"
)
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
assert os.system(f"python3 {script_path} {command_args}") != 0 assert os.system(f"python3 {script_path} {command_args}") != 0
@ -52,7 +54,7 @@ def test_no_volume():
def test_readin_list(): def test_readin_list():
list_file = "tests/test_list.txt" list_file = "tests/test_list.txt"
test_list = mdlpinput.readin_list(None, None, list_file) test_list = mdlpinput.readin_list(list_file)
assert test_list == [ assert test_list == [
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu", "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu",

View file

@ -1,193 +0,0 @@
import shutil
import subprocess
import time
from pathlib import Path
import pytest
from pytest import MonkeyPatch
@pytest.fixture
def wait_10s():
print("sleeping 10 seconds because of api timeouts")
time.sleep(10)
@pytest.fixture
def wait_20s():
print("sleeping 20 seconds because of api timeouts")
time.sleep(20)
def test_manga_pre_hook(wait_10s: MonkeyPatch):
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
manga_path = Path("tests/Shikimori's Not Just a Cutie")
language = "en"
chapters = "1"
download_path = "tests"
manga_pre_hook = "touch tests/manga-pre.txt"
hook_file = Path("tests/manga-pre.txt")
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--debug",
"--hook-manga-pre",
manga_pre_hook,
]
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert hook_file.is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
hook_file.unlink()
def test_manga_post_hook(wait_10s: MonkeyPatch):
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
manga_path = Path("tests/Shikimori's Not Just a Cutie")
language = "en"
chapters = "1"
download_path = "tests"
manga_post_hook = "touch tests/manga-post.txt"
hook_file = Path("tests/manga-post.txt")
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--debug",
"--hook-manga-post",
manga_post_hook,
]
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert hook_file.is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
hook_file.unlink()
def test_chapter_pre_hook(wait_10s: MonkeyPatch):
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
manga_path = Path("tests/Shikimori's Not Just a Cutie")
language = "en"
chapters = "1"
download_path = "tests"
chapter_pre_hook = "touch tests/chapter-pre.txt"
hook_file = Path("tests/chapter-pre.txt")
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--debug",
"--hook-chapter-pre",
chapter_pre_hook,
]
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert hook_file.is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
hook_file.unlink()
def test_chapter_post_hook(wait_10s: MonkeyPatch):
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
manga_path = Path("tests/Shikimori's Not Just a Cutie")
language = "en"
chapters = "1"
download_path = "tests"
chapter_post_hook = "touch tests/chapter-post.txt"
hook_file = Path("tests/chapter-post.txt")
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--debug",
"--hook-chapter-post",
chapter_post_hook,
]
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert hook_file.is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
hook_file.unlink()
def test_all_hooks(wait_10s: MonkeyPatch):
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
manga_path = Path("tests/Shikimori's Not Just a Cutie")
language = "en"
chapters = "1"
download_path = "tests"
manga_pre_hook = "touch tests/manga-pre2.txt"
manga_post_hook = "touch tests/manga-post2.txt"
chapter_pre_hook = "touch tests/chapter-pre2.txt"
chapter_post_hook = "touch tests/chapter-post2.txt"
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--debug",
"--hook-manga-pre",
manga_pre_hook,
"--hook-manga-post",
manga_post_hook,
"--hook-chapter-pre",
chapter_pre_hook,
"--hook-chapter-post",
chapter_post_hook,
]
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert Path("tests/manga-pre2.txt").is_file()
assert Path("tests/manga-post2.txt").is_file()
assert Path("tests/chapter-pre2.txt").is_file()
assert Path("tests/chapter-post2.txt").is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
Path("tests/manga-pre2.txt").unlink()
Path("tests/manga-post2.txt").unlink()
Path("tests/chapter-pre2.txt").unlink()
Path("tests/chapter-post2.txt").unlink()

View file

@ -1,80 +0,0 @@
import json
from pathlib import Path
from mangadlp.cache import CacheDB
def test_cache_creation():
cache_file = Path("cache.json")
CacheDB(cache_file, "abc", "en", "test")
assert cache_file.exists()
cache_file.unlink()
def test_cache_insert():
cache_file = Path("cache.json")
cache = CacheDB(cache_file, "abc", "en", "test")
cache.add_chapter("1")
cache.add_chapter("2")
cache_data = json.loads(cache_file.read_text(encoding="utf8"))
assert cache_data["abc__en"]["chapters"] == ["1", "2"]
assert cache_data["abc__en"]["name"] == "test"
cache_file.unlink()
def test_cache_update():
cache_file = Path("cache.json")
cache = CacheDB(cache_file, "abc", "en", "test")
cache.add_chapter("1")
cache.add_chapter("2")
cache_data = json.loads(cache_file.read_text(encoding="utf8"))
assert cache_data["abc__en"]["chapters"] == ["1", "2"]
cache.add_chapter("3")
cache_data = json.loads(cache_file.read_text(encoding="utf8"))
assert cache_data["abc__en"]["chapters"] == ["1", "2", "3"]
cache_file.unlink()
def test_cache_multiple():
cache_file = Path("cache.json")
cache1 = CacheDB(cache_file, "abc", "en", "test")
cache1.add_chapter("1")
cache1.add_chapter("2")
cache2 = CacheDB(cache_file, "def", "en", "test2")
cache2.add_chapter("8")
cache2.add_chapter("9")
cache_data = json.loads(cache_file.read_text(encoding="utf8"))
assert cache_data["abc__en"]["chapters"] == ["1", "2"]
assert cache_data["abc__en"]["name"] == "test"
assert cache_data["def__en"]["chapters"] == ["8", "9"]
assert cache_data["def__en"]["name"] == "test2"
cache_file.unlink()
def test_cache_lang():
cache_file = Path("cache.json")
cache1 = CacheDB(cache_file, "abc", "en", "test")
cache1.add_chapter("1")
cache1.add_chapter("2")
cache2 = CacheDB(cache_file, "abc", "de", "test")
cache2.add_chapter("8")
cache2.add_chapter("9")
cache_data = json.loads(cache_file.read_text(encoding="utf8"))
assert cache_data["abc__en"]["chapters"] == ["1", "2"]
assert cache_data["abc__de"]["chapters"] == ["8", "9"]
cache_file.unlink()

View file

@ -1,146 +0,0 @@
import shutil
import subprocess
import time
from pathlib import Path
import pytest
import xmlschema
from pytest import MonkeyPatch
from mangadlp.metadata import validate_metadata, write_metadata
@pytest.fixture
def wait_20s():
print("sleeping 20 seconds because of api timeouts")
time.sleep(20)
def test_metadata_creation():
test_metadata_file = Path("tests/ComicInfo_test.xml")
metadata_path = Path("tests/")
metadata_file = Path("tests/ComicInfo.xml")
metadata = {
"Volume": 1,
"Number": "2",
"PageCount": 99,
"Count": 10,
"LanguageISO": "en",
"Title": "title1",
"Series": "series1",
"Summary": "summary1",
"Genre": "genre1",
"Web": "https://mangadex.org",
"Format": "cbz",
}
write_metadata(metadata_path, metadata)
assert metadata_file.exists()
read_in_metadata = metadata_file.read_text(encoding="utf8")
test_metadata = test_metadata_file.read_text(encoding="utf8")
assert test_metadata == read_in_metadata
# cleanup
metadata_file.unlink()
def test_metadata_validation():
metadata = {
"Volume": "1", # invalid
"Number": "2",
"PageCount": "99", # invalid
"Count": "10", # invalid
"LanguageISO": 1, # invalid
"Title": "title1",
"Series": "series1",
"Summary": "summary1",
"Genre": "genre1",
"Web": "https://mangadex.org",
"Format": "cbz",
}
valid_metadata = validate_metadata(metadata)
assert valid_metadata["ComicInfo"] == {
"Title": "title1",
"Series": "series1",
"Number": "2",
"Summary": "summary1",
"Notes": "Downloaded with https://github.com/olofvndrhr/manga-dlp",
"Genre": "genre1",
"Web": "https://mangadex.org",
"Format": "cbz",
"Manga": "Yes",
}
def test_metadata_validation_values():
metadata = {
"BlackAndWhite": "No",
"Manga": "YesAndRightToLeft",
"AgeRating": "Rating Pending",
"CommunityRating": 4,
}
valid_metadata = validate_metadata(metadata)
assert valid_metadata["ComicInfo"] == {
"Notes": "Downloaded with https://github.com/olofvndrhr/manga-dlp",
"BlackAndWhite": "No",
"Manga": "YesAndRightToLeft",
"AgeRating": "Rating Pending",
"CommunityRating": 4,
}
def test_metadata_validation_values2():
metadata = {
"BlackAndWhite": "No",
"Manga": "YesAndRightToLeft",
"AgeRating": "12+", # invalid
"CommunityRating": 10, # invalid
}
valid_metadata = validate_metadata(metadata)
assert valid_metadata["ComicInfo"] == {
"Notes": "Downloaded with https://github.com/olofvndrhr/manga-dlp",
"BlackAndWhite": "No",
"Manga": "YesAndRightToLeft",
}
def test_metadata_chapter_validity(wait_20s: MonkeyPatch):
url_uuid = (
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
manga_path = Path("tests/Tomo-chan wa Onna no ko")
metadata_path = manga_path / "Ch. 1 - Once In A Life Time Misfire/ComicInfo.xml"
language = "en"
chapters = "1"
download_path = "tests"
command_args = [
"-u",
url_uuid,
"-l",
language,
"-c",
chapters,
"--path",
download_path,
"--format",
"",
"--debug",
]
schema = xmlschema.XMLSchema("src/mangadlp/metadata/ComicInfo_v2.0.xsd")
script_path = "manga-dlp.py"
command = ["python3", script_path, *command_args]
assert subprocess.call(command) == 0
assert metadata_path.is_file()
assert schema.is_valid(metadata_path)
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)

View file

@ -1,14 +1,11 @@
import pytest import pytest
import requests import requests
from pytest import MonkeyPatch
from mangadlp.api.mangadex import Mangadex from mangadlp.api.mangadex import Mangadex
def test_uuid_link(): def test_uuid_link():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -30,15 +27,14 @@ def test_uuid_link_false():
language = "en" language = "en"
forcevol = False forcevol = False
with pytest.raises(Exception) as e: with pytest.raises(SystemExit) as e:
Mangadex(url_uuid, language, forcevol) Mangadex(url_uuid, language, forcevol)
assert e.type == TypeError assert e.type == SystemExit
assert e.value.code == 1
def test_title(): def test_title():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -46,34 +42,12 @@ def test_title():
assert test.manga_title == "Komi-san wa Komyushou Desu" assert test.manga_title == "Komi-san wa Komyushou Desu"
def test_alt_title():
url_uuid = "https://mangadex.org/title/5a90308a-8b12-4a4d-9c6d-2487028fe319/uzaki-chan-wants-to-hang-out"
language = "fr"
forcevol = False
test = Mangadex(url_uuid, language, forcevol)
assert test.manga_title == "Uzaki-chan wants to hang out"
def test_alt_title_fallback():
url_uuid = (
"https://mangadex.org/title/d7037b2a-874a-4360-8a7b-07f2899152fd/mairimashita-iruma-kun"
)
language = "fr"
forcevol = False
test = Mangadex(url_uuid, language, forcevol)
assert test.manga_title == "Iruma à lécole des démons" # noqa
def test_chapter_infos(): def test_chapter_infos():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
chapter_infos = test.manga_chapter_data["1"] chapter_infos = test.get_chapter_infos("1")
chapter_uuid = chapter_infos["uuid"] chapter_uuid = chapter_infos["uuid"]
chapter_name = chapter_infos["name"] chapter_name = chapter_infos["name"]
chapter_num = chapter_infos["chapter"] chapter_num = chapter_infos["chapter"]
@ -88,35 +62,33 @@ def test_chapter_infos():
def test_non_existing_manga(): def test_non_existing_manga():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-999999999999/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-999999999999/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
with pytest.raises(Exception) as e: with pytest.raises(SystemExit) as e:
Mangadex(url_uuid, language, forcevol) Mangadex(url_uuid, language, forcevol)
assert e.type == KeyError assert e.type == SystemExit
assert e.value.code == 1
def test_api_failure(monkeypatch: MonkeyPatch): def test_api_failure(monkeypatch):
fail_url = "https://api.mangadex.nonexistant/manga/a96676e5-8ae2-425e-b549-7f15dd34a6d8" fail_url = (
"https://api.mangadex.nonexistant/manga/a96676e5-8ae2-425e-b549-7f15dd34a6d8"
)
monkeypatch.setattr(requests, "get", fail_url) monkeypatch.setattr(requests, "get", fail_url)
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
with pytest.raises(Exception) as e: with pytest.raises(SystemExit) as e:
Mangadex(url_uuid, language, forcevol) Mangadex(url_uuid, language, forcevol)
assert e.type == TypeError assert e.type == SystemExit
assert e.value.code == 1
def test_chapter_lang_en(): def test_chapter_lang_en():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -125,31 +97,32 @@ def test_chapter_lang_en():
def test_empty_chapter_lang(): def test_empty_chapter_lang():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "ch" language = "ch"
forcevol = False forcevol = False
with pytest.raises(Exception) as e: with pytest.raises(SystemExit) as e:
Mangadex(url_uuid, language, forcevol) Mangadex(url_uuid, language, forcevol)
assert e.type == KeyError Mangadex(url_uuid, language, forcevol).check_chapter_lang()
assert e.type == KeyError or e.type == SystemExit
assert e.value.code == 1
def test_not_existing_lang(): def test_not_existing_lang():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "zz" language = "zz"
forcevol = False forcevol = False
with pytest.raises(Exception) as e: with pytest.raises(SystemExit) as e:
Mangadex(url_uuid, language, forcevol) Mangadex(url_uuid, language, forcevol)
assert e.type == KeyError assert e.type == SystemExit
assert e.value.code == 1
def test_create_chapter_list(): def test_create_chapter_list():
url_uuid = "https://mangadex.org/title/6fef1f74-a0ad-4f0d-99db-d32a7cd24098/fire-punch" url_uuid = (
"https://mangadex.org/title/6fef1f74-a0ad-4f0d-99db-d32a7cd24098/fire-punch"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -175,76 +148,15 @@ def test_create_chapter_list():
"19", "19",
"20", "20",
"21", "21",
"22",
"23",
"24",
"25",
"26",
"27",
"28",
"29",
"30",
"31",
"32",
"33",
"34",
"34.5",
"35",
"36",
"37",
"38",
"39",
"40",
"41",
"42",
"43",
"44",
"45",
"46",
"47",
"48",
"49",
"50",
"51",
"52",
"53",
"54",
"55",
"56",
"57",
"58",
"59",
"60",
"61",
"62",
"63",
"64",
"65",
"66",
"67",
"68",
"69",
"70",
"71",
"72",
"73",
"74",
"75",
"76",
"77",
"78",
"79",
"80",
"81",
"82",
"83",
] ]
assert test.create_chapter_list() == test_list assert test.create_chapter_list() == test_list
def test_create_chapter_list_forcevol(): def test_create_chapter_list_forcevol():
url_uuid = "https://mangadex.org/title/6fef1f74-a0ad-4f0d-99db-d32a7cd24098/fire-punch" url_uuid = (
"https://mangadex.org/title/6fef1f74-a0ad-4f0d-99db-d32a7cd24098/fire-punch"
)
language = "en" language = "en"
forcevol = True forcevol = True
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -270,83 +182,19 @@ def test_create_chapter_list_forcevol():
"3:19", "3:19",
"3:20", "3:20",
"3:21", "3:21",
"3:22",
"3:23",
"3:24",
"3:25",
"3:26",
"3:27",
"3:28",
"4:29",
"4:30",
"4:31",
"4:32",
"4:33",
"4:34",
"4:34.5",
"4:35",
"4:36",
"4:37",
"4:38",
"4:39",
"5:40",
"5:41",
"5:42",
"5:43",
"5:44",
"5:45",
"5:46",
"5:47",
"5:48",
"5:49",
"6:50",
"6:51",
"6:52",
"6:53",
"6:54",
"6:55",
"6:56",
"6:57",
"6:58",
"6:59",
"6:60",
"7:61",
"7:62",
"7:63",
"7:64",
"7:65",
"7:66",
"7:67",
"7:68",
"7:69",
"7:70",
"8:71",
"8:72",
"8:73",
"8:74",
"8:75",
"8:76",
"8:77",
"8:78",
"8:79",
"8:80",
"8:81",
"8:82",
"8:83",
] ]
assert test.create_chapter_list() == test_list assert test.create_chapter_list() == test_list
def test_get_chapter_images(): def test_get_chapter_images():
url_uuid = ( url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
img_base_url = "https://uploads.mangadex.org" img_base_url = "https://uploads.mangadex.org"
chapter_hash = "0752bc5db298beff6b932b9151dd8437" chapter_hash = "0752bc5db298beff6b932b9151dd8437"
chapter_uuid = "e86ec2c4-c5e4-4710-bfaa-7604f00939c7"
chapter_num = "1" chapter_num = "1"
test_list = [ test_list = [
f"{img_base_url}/data/{chapter_hash}/x1-0deb4c9bfedd5be49e0a90cfb17cf343888239898c9e7451d569c0b3ea2971f4.jpg", f"{img_base_url}/data/{chapter_hash}/x1-0deb4c9bfedd5be49e0a90cfb17cf343888239898c9e7451d569c0b3ea2971f4.jpg",
@ -367,11 +215,11 @@ def test_get_chapter_images():
assert test.get_chapter_images(chapter_num, 2) == test_list assert test.get_chapter_images(chapter_num, 2) == test_list
def test_get_chapter_images_error(monkeypatch: MonkeyPatch): def test_get_chapter_images_error(monkeypatch):
fail_url = "https://api.mangadex.org/at-home/server/e86ec2c4-c5e4-4710-bfaa-999999999999" fail_url = (
url_uuid = ( "https://api.mangadex.org/at-home/server/e86ec2c4-c5e4-4710-bfaa-999999999999"
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
) )
url_uuid = "https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
language = "en" language = "en"
forcevol = False forcevol = False
test = Mangadex(url_uuid, language, forcevol) test = Mangadex(url_uuid, language, forcevol)
@ -379,26 +227,3 @@ def test_get_chapter_images_error(monkeypatch: MonkeyPatch):
monkeypatch.setattr(requests, "get", fail_url) monkeypatch.setattr(requests, "get", fail_url)
assert not test.get_chapter_images(chapter_num, 2) assert not test.get_chapter_images(chapter_num, 2)
def test_chapter_metadata():
url_uuid = (
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8/komi-san-wa-komyushou-desu"
)
language = "en"
forcevol = False
test = Mangadex(url_uuid, language, forcevol)
chapter_metadata = test.create_metadata("1")
manga_name = chapter_metadata["Series"]
chapter_name = chapter_metadata["Title"]
chapter_num = chapter_metadata["Number"]
chapter_volume = chapter_metadata["Volume"]
chapter_url = chapter_metadata["Web"]
assert (manga_name, chapter_name, chapter_volume, chapter_num, chapter_url) == (
"Komi-san wa Komyushou Desu",
"A Normal Person",
1,
"1",
"https://mangadex.org/title/a96676e5-8ae2-425e-b549-7f15dd34a6d8",
)

View file

@ -3,12 +3,10 @@ import platform
import shutil import shutil
import time import time
from pathlib import Path from pathlib import Path
from typing import List
import pytest import pytest
from pytest import MonkeyPatch
from mangadlp import app import mangadlp.app as app
@pytest.fixture @pytest.fixture
@ -23,11 +21,11 @@ def wait_20s():
time.sleep(20) time.sleep(20)
def test_full_api_mangadex(wait_20s: MonkeyPatch): def test_full_api_mangadex(wait_20s):
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
mdlp = app.MangaDLP( mdlp = app.MangaDLP(
url_uuid="https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko", url_uuid="https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie",
language="en", language="en",
chapters="1", chapters="1",
list_chapters=False, list_chapters=False,
@ -35,6 +33,7 @@ def test_full_api_mangadex(wait_20s: MonkeyPatch):
forcevol=False, forcevol=False,
download_path="tests", download_path="tests",
download_wait=2, download_wait=2,
verbosity=10,
) )
mdlp.get_manga() mdlp.get_manga()
@ -44,16 +43,14 @@ def test_full_api_mangadex(wait_20s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_input_cbz(wait_20s: MonkeyPatch): def test_full_with_input_cbz(wait_20s):
url_uuid = ( url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
@ -64,16 +61,14 @@ def test_full_with_input_cbz(wait_20s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_input_cbz_info(wait_20s: MonkeyPatch): def test_full_with_input_cbz_info(wait_20s):
url_uuid = ( url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
@ -84,17 +79,18 @@ def test_full_with_input_cbz_info(wait_20s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
@pytest.mark.skipif(platform.machine() != "x86_64", reason="pdf only supported on amd64") def test_full_with_input_pdf(wait_20s):
def test_full_with_input_pdf(wait_20s: MonkeyPatch): # check if its arm64, if yes skip this step
url_uuid = ( if platform.machine() != "x86_64":
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko" return True
)
url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "pdf" file_format = "pdf"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.pdf") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.pdf")
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
@ -105,40 +101,32 @@ def test_full_with_input_pdf(wait_20s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_input_folder(wait_20s: MonkeyPatch): def test_full_with_input_folder(wait_20s):
url_uuid = ( url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "" file_format = ""
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1")
metadata_path = Path(
"tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire/ComicInfo.xml"
)
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format '{file_format}' --debug --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format '{file_format}' --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
assert manga_path.exists() and manga_path.is_dir() assert manga_path.exists() and manga_path.is_dir()
assert chapter_path.exists() and chapter_path.is_dir() assert chapter_path.exists() and chapter_path.is_dir()
assert metadata_path.exists() and metadata_path.is_file()
# cleanup # cleanup
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_input_skip_cbz(wait_10s: MonkeyPatch): def test_full_with_input_skip_cbz(wait_10s):
url_uuid = ( url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
manga_path.mkdir(parents=True, exist_ok=True) manga_path.mkdir(parents=True, exist_ok=True)
@ -151,49 +139,43 @@ def test_full_with_input_skip_cbz(wait_10s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_input_skip_folder(wait_10s: MonkeyPatch): def test_full_with_input_skip_folder(wait_10s):
url_uuid = ( url_uuid = "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko"
)
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "" file_format = ""
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1")
command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format '{file_format}' --debug --wait 2" command_args = f"-u {url_uuid} -l {language} -c {chapters} --path {download_path} --format '{file_format}' --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
chapter_path.mkdir(parents=True, exist_ok=True) chapter_path.mkdir(parents=True, exist_ok=True)
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
found_files: List[str] = [] found_files = []
for file in chapter_path.iterdir(): for file in chapter_path.iterdir():
found_files.append(file.name) found_files.append(file.name)
assert chapter_path.is_dir() assert chapter_path.is_dir()
assert found_files == [] assert found_files == []
assert not Path( assert not Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz").exists()
"tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz" assert not Path("tests/Shikimori's Not Just a Cutie/Ch. 1.zip").exists()
).exists()
assert not Path(
"tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.zip"
).exists()
# cleanup # cleanup
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_read_cbz(wait_20s: MonkeyPatch): def test_full_with_read_cbz(wait_20s):
url_list = Path("tests/test_list2.txt") url_list = Path("tests/test_list2.txt")
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
command_args = f"--read {url_list!s} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2" command_args = f"--read {str(url_list)} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
url_list.write_text( url_list.write_text(
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko" "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
) )
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")
@ -204,20 +186,20 @@ def test_full_with_read_cbz(wait_20s: MonkeyPatch):
shutil.rmtree(manga_path, ignore_errors=True) shutil.rmtree(manga_path, ignore_errors=True)
def test_full_with_read_skip_cbz(wait_10s: MonkeyPatch): def test_full_with_read_skip_cbz(wait_10s):
url_list = Path("tests/test_list2.txt") url_list = Path("tests/test_list2.txt")
language = "en" language = "en"
chapters = "1" chapters = "1"
file_format = "cbz" file_format = "cbz"
download_path = "tests" download_path = "tests"
manga_path = Path("tests/Tomo-chan wa Onna no ko") manga_path = Path("tests/Shikimori's Not Just a Cutie")
chapter_path = Path("tests/Tomo-chan wa Onna no ko/Ch. 1 - Once In A Life Time Misfire.cbz") chapter_path = Path("tests/Shikimori's Not Just a Cutie/Ch. 1.cbz")
command_args = f"--read {url_list!s} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2" command_args = f"--read {str(url_list)} -l {language} -c {chapters} --path {download_path} --format {file_format} --debug --wait 2"
script_path = "manga-dlp.py" script_path = "manga-dlp.py"
manga_path.mkdir(parents=True, exist_ok=True) manga_path.mkdir(parents=True, exist_ok=True)
chapter_path.touch() chapter_path.touch()
url_list.write_text( url_list.write_text(
"https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko" "https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie"
) )
os.system(f"python3 {script_path} {command_args}") os.system(f"python3 {script_path} {command_args}")

View file

@ -1,52 +0,0 @@
import os
import shutil
import time
from pathlib import Path
import pytest
from pytest import MonkeyPatch
@pytest.fixture
def wait_10s():
print("sleeping 10 seconds because of api timeouts")
time.sleep(10)
@pytest.fixture
def wait_20s():
print("sleeping 20 seconds because of api timeouts")
time.sleep(20)
def test_full_with_all_flags(wait_20s: MonkeyPatch):
manga_path = Path("tests/Tomo-chan wa Onna no ko")
chapter_path = manga_path / "Ch. 1 - Once In A Life Time Misfire.cbz"
cache_path = Path("tests/test_cache.json")
flags = [
"-u https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko",
"--loglevel 10",
"-l en",
"-c 1",
"--path tests",
"--format cbz",
"--name-format 'Ch. {chapter_num} - {chapter_name}'",
"--name-format-none 0",
# "--forcevol",
"--wait 2",
"--hook-manga-pre 'echo 0'",
"--hook-manga-post 'echo 1'",
"--hook-chapter-pre 'echo 2'",
"--hook-chapter-post 'echo 3'",
"--cache-path tests/test_cache.json",
"--add-metadata",
]
script_path = "manga-dlp.py"
os.system(f"python3 {script_path} {' '.join(flags)}")
assert manga_path.exists() and manga_path.is_dir()
assert chapter_path.exists() and chapter_path.is_file()
assert cache_path.exists() and cache_path.is_file()
# cleanup
shutil.rmtree(manga_path, ignore_errors=True)
cache_path.unlink(missing_ok=True)

View file

@ -1 +1 @@
https://mangadex.org/title/76ee7069-23b4-493c-bc44-34ccbf3051a8/tomo-chan-wa-onna-no-ko https://mangadex.org/title/0aea9f43-d4a9-4bf7-bebc-550a512f9b95/shikimori-s-not-just-a-cutie

26
tox.ini Normal file
View file

@ -0,0 +1,26 @@
[tox]
envlist = py38, py39, py310
isolated_build = True
[testenv]
deps =
-rcontrib/requirements_dev.txt
commands =
pytest --exitfirst --basetemp="{envtmpdir}" {posargs}
[testenv:basic]
deps =
-rcontrib/requirements_dev.txt
commands =
pytest --exitfirst --basetemp="{envtmpdir}" {posargs}
[testenv:coverage]
deps =
-rcontrib/requirements_dev.txt
commands =
coverage erase
coverage run
coverage xml -i