├── .github ├── ISSUE_TEMPLATE │ └── issue.md └── workflows │ ├── Run_Tests.yml │ └── workflow.yml ├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE ├── MangaTagger.py ├── MangaTaggerLib ├── MangaTaggerLib.py ├── __init__.py ├── _version.py ├── api.py ├── database.py ├── errors.py ├── models.py ├── task_queue.py └── utils.py ├── README.md ├── images └── manga_tagger_logo_cropped.png ├── requirements.txt ├── root └── etc │ ├── cont-init.d │ └── 30-config │ └── services.d │ └── mangatagger │ └── run ├── settings.json └── tests ├── __init__.py ├── data ├── 3D Kanojo Real Girl │ └── data.json ├── BLEACH │ └── data.json ├── Hurejasik │ └── data.json └── Naruto │ └── data.json ├── database.py ├── test_integration.py └── test_manga.py /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Log bugs found while using Manga Tagger 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: Inpacchi 7 | 8 | --- 9 | 10 | **OS**: What operating system are you running Manga Tagger on? 11 | 12 | **Manga Tagger Version:** What version of Manga Tagger are you running? 13 | 14 | **Description of the issue:** Give a a clear and concise description of what the issue is. 15 | 16 | **Manga Series:** What manga series (singular or plural) were being tagged when the issue was encountered? 17 | 18 | **Screenshots (If applicable):** Add screenshots to help explain your problem. 19 | 20 | **Log File**: Please make sure JSON logging is enabled and upload the JSON log file. 21 | -------------------------------------------------------------------------------- /.github/workflows/Run_Tests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | # env: 10 | # DISPLAY: ":99.0" 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ '3.8','3.9', ] 15 | name: Python ${{ matrix.python-version }} sample 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v3 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | cache: 'pip' 22 | - run: pip install -r requirements.txt 23 | # - run: sudo apt install xvfb 24 | # - name: Start xvfb 25 | # run: | 26 | # Xvfb :99 -screen 0 1920x1080x24 &disown 27 | - name: Run the tests 28 | run: python3 -m unittest discover -s tests -t . 29 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | # This workflow file will install Python dependencies, 2 | # create a desktop, joystick, and test the application's GUI on multiple versions of Python 3 | name: Python tests & Build 4 | 5 | on: 6 | - push 7 | # - pull_request 8 | env: 9 | IMAGE_NAME: "banhcanh/manga-tagger" 10 | jobs: 11 | build: 12 | # env: 13 | # DISPLAY: ":99.0" 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ '3.8','3.9', ] 18 | name: Python ${{ matrix.python-version }} sample 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: 'pip' 25 | - run: pip install -r requirements.txt 26 | - name: Run the tests 27 | run: python3 -m unittest discover -s tests -t . 28 | # sonarcloud: 29 | # name: SonarCloud 30 | # runs-on: ubuntu-latest 31 | # steps: 32 | # - uses: actions/checkout@v2 33 | # with: 34 | # fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 35 | # - name: SonarCloud Scan 36 | # uses: SonarSource/sonarcloud-github-action@master 37 | # env: 38 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 39 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 40 | docker_develop: 41 | name: Nightly Build 42 | needs: [ build ] 43 | runs-on: ubuntu-latest 44 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} # Only run in develop push 45 | steps: 46 | - name: Set up QEMU 47 | uses: docker/setup-qemu-action@v2 48 | - name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v2 50 | - name: Login to DockerHub 51 | uses: docker/login-action@v2 52 | with: 53 | username: ${{ secrets.DOCKERHUB_USERNAME }} 54 | password: ${{ secrets.DOCKERHUB_TOKEN }} 55 | - name: Build and push 56 | uses: docker/build-push-action@v3 57 | with: 58 | push: true 59 | tags: ${{ env.IMAGE_NAME }}:nightly 60 | docker_stable: 61 | name: Stable Build 62 | needs: [ build ] 63 | runs-on: ubuntu-latest 64 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} # Only run in master push 65 | steps: 66 | - name: Set up QEMU 67 | uses: docker/setup-qemu-action@v2 68 | - name: Set up Docker Buildx 69 | uses: docker/setup-buildx-action@v2 70 | - name: Login to DockerHub 71 | uses: docker/login-action@v2 72 | with: 73 | username: ${{ secrets.DOCKERHUB_USERNAME }} 74 | password: ${{ secrets.DOCKERHUB_TOKEN }} 75 | - name: Build and push 76 | uses: docker/build-push-action@v3 77 | with: 78 | push: true 79 | tags: ${{ env.IMAGE_NAME }}:latest 80 | docker_test: 81 | name: Test Build 82 | needs: [ build ] 83 | runs-on: ubuntu-latest 84 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/test' }} # Only run in test push 85 | steps: 86 | - name: Set up QEMU 87 | uses: docker/setup-qemu-action@v2 88 | - name: Set up Docker Buildx 89 | uses: docker/setup-buildx-action@v2 90 | - name: Login to DockerHub 91 | uses: docker/login-action@v2 92 | with: 93 | username: ${{ secrets.DOCKERHUB_USERNAME }} 94 | password: ${{ secrets.DOCKERHUB_TOKEN }} 95 | - name: Build and push 96 | uses: docker/build-push-action@v3 97 | with: 98 | push: true 99 | tags: ${{ env.IMAGE_NAME }}:test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IntelliJ 132 | .idea/ 133 | 134 | # Manga Tagger 135 | library/ 136 | downloads/ 137 | wiki/ 138 | data/ 139 | logs/ 140 | cover/ 141 | manga/ 142 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - build 4 | 5 | MT-filename-parser: 6 | stage: test 7 | image: alpine:latest 8 | script: 9 | #"Installing dependencies..." 10 | - apk add python3 py3-pip py3-numpy py3-multidict py3-yarl py3-psutil py3-watchdog py3-requests py3-tz build-base jpeg-dev zlib-dev python3-dev 11 | - pip3 install pymongo python_json_logger image BeautifulSoup4 12 | #"Testing filename parser and renamer..." 13 | - python3 -m unittest discover -s tests -p 'test_manga.py' 14 | 15 | MT-MetadataXMLConstruct: 16 | stage: test 17 | image: alpine:latest 18 | script: 19 | #"Installing dependencies..." 20 | - apk add python3 py3-pip py3-numpy py3-multidict py3-yarl py3-psutil py3-watchdog py3-requests py3-tz build-base jpeg-dev zlib-dev python3-dev 21 | - pip3 install pymongo python_json_logger image BeautifulSoup4 22 | #"Testing metadata tagger and xml construction" 23 | - python3 -m unittest discover -s tests -p 'test_integration.py' 24 | 25 | build: 26 | stage: build 27 | image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-build-image:v0.4.0" 28 | variables: 29 | DOCKER_TLS_CERTDIR: "" 30 | services: 31 | - docker:19.03.12-dind 32 | script: 33 | - | 34 | if [[ -z "$CI_COMMIT_TAG" ]]; then 35 | export CI_APPLICATION_REPOSITORY=${CI_APPLICATION_REPOSITORY:-$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG} 36 | export CI_APPLICATION_TAG=${CI_APPLICATION_TAG:-$CI_COMMIT_SHA} 37 | else 38 | export CI_APPLICATION_REPOSITORY=${CI_APPLICATION_REPOSITORY:-$CI_REGISTRY_IMAGE} 39 | export CI_APPLICATION_TAG=${CI_APPLICATION_TAG:-$CI_COMMIT_TAG} 40 | fi 41 | - /build/build.sh 42 | rules: 43 | - if: '$CI_COMMIT_TAG || $CI_COMMIT_BRANCH' 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/linuxserver/baseimage-alpine:3.13 2 | 3 | LABEL \ 4 | maintainer="TKVictor-Hang@outlook.fr" 5 | 6 | ### Set default Timezone, overwrite default MangaTagger settings for the container ### 7 | ENV \ 8 | TZ="Europe/Paris" \ 9 | MANGA_TAGGER_DEBUG_MODE=false \ 10 | MANGA_TAGGER_DATA_DIR="/config/data" \ 11 | MANGA_TAGGER_IMAGE_COVER=true \ 12 | MANGA_TAGGER_IMAGE_DIR="/config/cover" \ 13 | MANGA_TAGGER_ADULT_RESULT=false \ 14 | MANGA_TAGGER_DOWNLOAD_DIR="/downloads" \ 15 | MANGA_TAGGER_LIBRARY_DIR="/manga" \ 16 | MANGA_TAGGER_LOGGING_DIR="/config/logs" \ 17 | MANGA_TAGGER_DRY_RUN=false \ 18 | MANGA_TAGGER_DB_INSERT=false \ 19 | MANGA_TAGGER_RENAME_FILE=false \ 20 | MANGA_TAGGER_WRITE_COMICINFO=false \ 21 | MANGA_TAGGER_THREADS=8 \ 22 | MANGA_TAGGER_MAX_QUEUE_SIZE=0 \ 23 | MANGA_TAGGER_DB_NAME=manga_tagger \ 24 | MANGA_TAGGER_DB_HOST_ADDRESS=mangatagger-db \ 25 | MANGA_TAGGER_DB_PORT=27017 \ 26 | MANGA_TAGGER_DB_USERNAME=manga_tagger \ 27 | MANGA_TAGGER_DB_PASSWORD=Manga4LYFE \ 28 | MANGA_TAGGER_DB_AUTH_SOURCE=admin \ 29 | MANGA_TAGGER_DB_SELECTION_TIMEOUT=10000 \ 30 | MANGA_TAGGER_LOGGING_LEVEL=info \ 31 | MANGA_TAGGER_LOGGING_CONSOLE=true \ 32 | MANGA_TAGGER_LOGGING_FILE=true \ 33 | MANGA_TAGGER_LOGGING_JSON=false \ 34 | MANGA_TAGGER_LOGGING_TCP=false \ 35 | MANGA_TAGGER_LOGGING_JSONTCP=false 36 | 37 | ### Upgrade ### 38 | RUN \ 39 | apk update && apk upgrade 40 | 41 | ### Manga Tagger ### 42 | COPY . /app/Manga-Tagger 43 | 44 | RUN \ 45 | echo "Installing Manga-Tagger" 46 | 47 | COPY root/ / 48 | 49 | ### Dependencies ### 50 | RUN \ 51 | echo "Install dependencies" && \ 52 | echo "@testing http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ 53 | apk add --no-cache --update \ 54 | python3 py3-pip py3-numpy py3-multidict py3-yarl \ 55 | py3-psutil py3-watchdog py3-requests py3-tz \ 56 | build-base jpeg-dev zlib-dev python3-dev && \ 57 | pip3 install --no-cache-dir -r /app/Manga-Tagger/requirements.txt && \ 58 | mkdir /manga && \ 59 | mkdir /downloads 60 | 61 | VOLUME /config 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yovarni Yearwood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MangaTagger.py: -------------------------------------------------------------------------------- 1 | from MangaTaggerLib import MangaTaggerLib 2 | 3 | if __name__ == '__main__': 4 | MangaTaggerLib.main() 5 | -------------------------------------------------------------------------------- /MangaTaggerLib/MangaTaggerLib.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import re 4 | import requests 5 | import unicodedata 6 | import shutil 7 | import json 8 | 9 | from datetime import datetime 10 | from os import path 11 | from pathlib import Path 12 | from PIL import Image 13 | from requests.exceptions import ConnectionError 14 | from xml.etree.ElementTree import SubElement, Element, Comment, tostring 15 | from xml.dom.minidom import parseString 16 | from zipfile import ZipFile 17 | from bs4 import BeautifulSoup 18 | 19 | from MangaTaggerLib._version import __version__ 20 | from MangaTaggerLib.api import AniList, AniListRateLimit 21 | from MangaTaggerLib.database import MetadataTable, ProcFilesTable, ProcSeriesTable 22 | from MangaTaggerLib.errors import FileAlreadyProcessedError, FileUpdateNotRequiredError, UnparsableFilenameError, \ 23 | MangaNotFoundError, MangaMatchedException 24 | from MangaTaggerLib.models import Metadata 25 | from MangaTaggerLib.task_queue import QueueWorker 26 | from MangaTaggerLib.utils import AppSettings, compare 27 | 28 | # Global Variable Declaration 29 | LOG = logging.getLogger('MangaTaggerLib.MangaTaggerLib') 30 | 31 | CURRENTLY_PENDING_RENAME = set() 32 | 33 | 34 | def main(): 35 | AppSettings.load() 36 | 37 | LOG.info(f'Starting Manga Tagger - Version {__version__}') 38 | LOG.debug('RUNNING WITH DEBUG LOG') 39 | 40 | if AppSettings.mode_settings is not None: 41 | LOG.info('DRY RUN MODE ENABLED') 42 | LOG.info(f"MetadataTable Insertion: {AppSettings.mode_settings['database_insert']}") 43 | LOG.info(f"Renaming Files: {AppSettings.mode_settings['rename_file']}") 44 | LOG.info(f"Writing Comicinfo.xml: {AppSettings.mode_settings['write_comicinfo']}") 45 | 46 | QueueWorker.run() 47 | 48 | 49 | def process_manga_chapter(file_path: Path, event_id): 50 | filename = file_path.name 51 | directory_path = file_path.parent 52 | directory_name = file_path.parent.name 53 | 54 | logging_info = { 55 | 'event_id': event_id, 56 | 'manga_title': directory_name, 57 | "original_filename": filename 58 | } 59 | 60 | LOG.info(f'Now processing "{file_path}"...', extra=logging_info) 61 | 62 | LOG.debug(f'filename: {filename}') 63 | LOG.debug(f'directory_path: {directory_path}') 64 | LOG.debug(f'directory_name: {directory_name}') 65 | 66 | manga_details = filename_parser(filename, logging_info) 67 | 68 | metadata_tagger(file_path, manga_details[0], manga_details[1], manga_details[2], logging_info, manga_details[3]) 69 | 70 | # Remove manga directory if empty 71 | try: 72 | LOG.info(f'Deleting {directory_path}...') 73 | if directory_path != AppSettings.download_dir: 74 | LOG.info(f'Deleting {directory_path}...') 75 | directory_path.rmdir() 76 | except OSError as e: 77 | LOG.info("Error: %s : %s" % (directory_path, e.strerror)) 78 | 79 | 80 | def filename_parser(filename, logging_info): 81 | LOG.info(f'Attempting to rename "{filename}"...', extra=logging_info) 82 | 83 | # Parse the manga title and chapter name/number (this depends on where the manga is downloaded from) 84 | try: 85 | if filename.find('-.-') == -1: 86 | raise UnparsableFilenameError(filename, '-.-') 87 | 88 | filename = filename.split(' -.- ') 89 | LOG.info(f'Filename was successfully parsed as {filename}.', extra=logging_info) 90 | except UnparsableFilenameError as ufe: 91 | LOG.exception(ufe, extra=logging_info) 92 | return None 93 | 94 | manga_title: str = filename[0] 95 | chapter_title: str = path.splitext(filename[1].lower())[0] 96 | manga_title = manga_title.strip() 97 | LOG.debug(f'manga_title: {manga_title}') 98 | LOG.debug(f'chapter: {chapter_title}') 99 | 100 | format = "MANGA" 101 | volume = re.findall(r"(?i)(?:Vol|v|volume)(?:\s|\.)?(?:\s|\.)?([0-9]+(?:\.[0-9]+)?)", chapter_title) 102 | if volume: 103 | volume = volume[0] 104 | else: 105 | volume = None 106 | 107 | chapter = re.findall( 108 | r"(?i)(?:(?:ch|chapter|c)(?:\s|\.)?(?:\s|\.)?(?:([0-9]+(?:\.[0-9]+)?)+(?:-([0-9]+(?:\.[0-9]+)?))?))", 109 | chapter_title) 110 | if chapter: 111 | chapter = f"{chapter[0][0]}" 112 | else: 113 | chapter = None 114 | # If "chapter" is in the chapter substring 115 | try: 116 | if not hasNumbers(chapter_title): 117 | if "oneshot" in chapter_title.lower(): 118 | format = "ONE_SHOT" 119 | chapter_title = "chap000" 120 | 121 | if "prologue" in chapter_title.lower(): 122 | chapter_title = chapter_title.replace(' ', '') 123 | chapter_title = re.sub('^\D*', '', chapter_title) 124 | chapter_title = "chap000." + chapter_title 125 | 126 | chapter_title = chapter_title.replace(' ', '') 127 | chapter_title = re.sub('\(\d*\)$', '', chapter_title) 128 | # Remove (1) (2) .. because it's often redundant and mess with parsing 129 | chapter_title = re.sub('\D*$', '', chapter_title) 130 | # Removed space and any character at the end of the chapter_title that are not number. Usually that's the name of the chapter. 131 | 132 | # Match "Chapter5" "GAME005" "Page/005" "ACT-50" "#505" "V05.5CHAP5.5" without the chapter number, we removed spaces above 133 | chapter_title_pattern = "[^\d\.]\D*\d*[.,]?\d*[^\d\.]\D*" 134 | 135 | if re.match(chapter_title_pattern, chapter_title): 136 | p = re.compile(chapter_title_pattern) 137 | prog = p.match(chapter_title) 138 | chapter_title_name = prog.group(0) 139 | delimiter = chapter_title_name 140 | delimiter_index = len(chapter_title_name) 141 | else: 142 | raise UnparsableFilenameError(filename, 'ch/chapter') 143 | except UnparsableFilenameError as ufe: 144 | LOG.exception(ufe, extra=logging_info) 145 | return None 146 | 147 | LOG.debug(f'delimiter: {delimiter}') 148 | LOG.debug(f'delimiter_index: {delimiter_index}') 149 | 150 | i = chapter_title.index(delimiter) + delimiter_index 151 | LOG.debug(f'Iterator i: {i}') 152 | LOG.debug(f'Length: {len(chapter_title)}') 153 | 154 | chapter_number = '' 155 | while i < len(chapter_title): 156 | substring = chapter_title[i] 157 | LOG.debug(f'substring: {substring}') 158 | 159 | if substring.isdigit() or substring == '.': 160 | chapter_number += chapter_title[i] 161 | i += 1 162 | 163 | LOG.debug(f'chapter_number: {chapter_number}') 164 | LOG.debug(f'Iterator i: {i}') 165 | else: 166 | break 167 | 168 | if chapter_number.find('.') == -1: 169 | chapter_number = chapter_number.zfill(3) 170 | else: 171 | chapter_number = chapter_number.zfill(5) 172 | 173 | LOG.debug(f'chapter_number: {chapter_number}') 174 | 175 | logging_info['chapter_number'] = chapter_number 176 | if chapter is not None: 177 | return manga_title, chapter, format, volume 178 | else: 179 | return manga_title, chapter_number, format, volume 180 | 181 | 182 | def rename_action(current_file_path: Path, new_file_path: Path, manga_title, chapter_number, logging_info): 183 | chapter_number = chapter_number.replace('.', '-') 184 | results = ProcFilesTable.search(manga_title, chapter_number) 185 | LOG.debug(f'Results: {results}') 186 | 187 | # If the series OR the chapter has not been processed 188 | if results is None: 189 | LOG.info(f'"{manga_title}" chapter {chapter_number} has not been processed before. ' 190 | f'Proceeding with file rename...', extra=logging_info) 191 | shutil.move(current_file_path, new_file_path) 192 | LOG.info(f'"{new_file_path.name.strip(".cbz")}" has been renamed.', extra=logging_info) 193 | ProcFilesTable.insert_record(current_file_path, new_file_path, manga_title, chapter_number, 194 | logging_info) 195 | else: 196 | versions = ['v2', 'v3', 'v4', 'v5'] 197 | 198 | existing_old_filename = results['old_filename'] 199 | existing_current_filename = results['new_filename'] 200 | 201 | # If currently processing file has the same name as an existing file 202 | if existing_current_filename == new_file_path.name: 203 | # If currently processing file has a version in it's filename 204 | if any(version in current_file_path.name.lower() for version in versions): 205 | # If the version is newer than the existing file 206 | if compare_versions(existing_old_filename, current_file_path.name): 207 | LOG.info(f'Newer version of "{manga_title}" chapter {chapter_number} has been found. Deleting ' 208 | f'existing file and proceeding with file rename...', extra=logging_info) 209 | new_file_path.unlink() 210 | LOG.info(f'"{new_file_path.name}" has been deleted! Proceeding to rename new file...', 211 | extra=logging_info) 212 | shutil.move(current_file_path, new_file_path) 213 | LOG.info(f'"{new_file_path.name.strip(".cbz")}" has been renamed.', extra=logging_info) 214 | ProcFilesTable.update_record(results, current_file_path, new_file_path, logging_info) 215 | else: 216 | LOG.warning(f'"{current_file_path.name}" was not renamed due being the exact same as the ' 217 | f'existing chapter; file currently being processed will be deleted', 218 | extra=logging_info) 219 | current_file_path.unlink() 220 | raise FileUpdateNotRequiredError(current_file_path.name) 221 | # If the current file doesn't have a version in it's filename, but the existing file does 222 | elif any(version in existing_old_filename.lower() for version in versions): 223 | LOG.warning(f'"{current_file_path.name}" was not renamed due to not being an updated version ' 224 | f'of the existing chapter; file currently being processed will be deleted', 225 | extra=logging_info) 226 | current_file_path.unlink() 227 | raise FileUpdateNotRequiredError(current_file_path.name) 228 | # If all else fails 229 | else: 230 | LOG.warning(f'No changes have been found for "{existing_current_filename}"; file currently being ' 231 | f'processed will be deleted', extra=logging_info) 232 | current_file_path.unlink() 233 | raise FileAlreadyProcessedError(current_file_path.name) 234 | 235 | LOG.info(f'"{new_file_path.name}" will be unlocked for any pending processes.', extra=logging_info) 236 | CURRENTLY_PENDING_RENAME.remove(new_file_path) 237 | 238 | 239 | def compare_versions(old_filename: str, new_filename: str): 240 | old_version = 0 241 | new_version = 0 242 | 243 | LOG.debug('Preprocessing') 244 | LOG.debug(f'Old Version: {old_version}') 245 | LOG.debug(f'New Version: {new_version}') 246 | 247 | if 'v2' in old_filename.lower(): 248 | old_version = 2 249 | elif 'v3' in old_filename.lower(): 250 | old_version = 3 251 | elif 'v4' in old_filename.lower(): 252 | old_version = 4 253 | elif 'v5' in old_filename.lower(): 254 | old_version = 5 255 | 256 | if 'v2' in new_filename.lower(): 257 | new_version = 2 258 | elif 'v3' in new_filename.lower(): 259 | new_version = 3 260 | elif 'v4' in new_filename.lower(): 261 | new_version = 4 262 | elif 'v5' in new_filename.lower(): 263 | new_version = 5 264 | 265 | LOG.debug('Postprocessing') 266 | LOG.debug(f'Old Version: {old_version}') 267 | LOG.debug(f'New Version: {new_version}') 268 | 269 | if new_version > old_version: 270 | return True 271 | else: 272 | return False 273 | 274 | 275 | def metadata_tagger(file_path, manga_title, manga_chapter_number, format, logging_info, volume): 276 | manga_search = None 277 | db_exists = True 278 | retries = 0 279 | isadult = False 280 | anilist_id = None 281 | 282 | if AppSettings.adult_result: 283 | isadult = True 284 | 285 | if Path(f'{AppSettings.data_dir}/exceptions.json').exists(): 286 | with open(f'{AppSettings.data_dir}/exceptions.json', 'r') as exceptions_json: 287 | exceptions = json.load(exceptions_json) 288 | if manga_title in exceptions: 289 | LOG.info('Manga_title found in exceptions.json, using manga specific configuration...', extra=logging_info) 290 | if exceptions[manga_title]['format'] == "MANGA" or exceptions[manga_title]['format'] == "ONE_SHOT": 291 | format = exceptions[manga_title]['format'] 292 | if exceptions[manga_title]['adult'] is True or exceptions[manga_title]['adult'] is False: 293 | isadult = exceptions[manga_title]['adult'] 294 | if "anilist_id" in exceptions[manga_title]: 295 | anilist_id = exceptions[manga_title]['anilist_id'] 296 | if "anilist_title" in exceptions[manga_title]: 297 | manga_title = exceptions[manga_title]['anilist_title'] 298 | 299 | LOG.info(f'Table search value is "{manga_title}"', extra=logging_info) 300 | while manga_search is None: 301 | if retries == 0: 302 | if anilist_id is not None: 303 | LOG.info('Searching manga_metadata for anilist id.', extra=logging_info) 304 | manga_search = MetadataTable.search_by_search_id(anilist_id) 305 | else: 306 | LOG.info('Searching manga_metadata for manga title by search value...', extra=logging_info) 307 | manga_search = MetadataTable.search_by_search_value(manga_title) 308 | retries = 1 309 | else: # The manga is not in the database, so ping the API and create the database 310 | LOG.info('Manga was not found in the database; resorting to Anilist API.', extra=logging_info) 311 | try: 312 | if anilist_id: 313 | LOG.info('Searching based on id given in exception file. ') 314 | manga_search = AniList.search_for_manga_title_by_id(anilist_id, logging_info) 315 | elif isadult: # enable adult result in Anilist 316 | LOG.info('Adult result enabled') 317 | manga_search = AniList.search_for_manga_title_by_manga_title_with_adult(manga_title, format, logging_info) 318 | else: 319 | manga_search = AniList.search_for_manga_title_by_manga_title(manga_title, format, logging_info) 320 | except AniListRateLimit as e: 321 | LOG.warning(e, extra=logging_info) 322 | LOG.warning('Manga Tagger has unintentionally breached the API limits on Anilist. Waiting 60s to clear ' 323 | 'all rate limiting limits...') 324 | time.sleep(60) 325 | if anilist_id: 326 | LOG.info('Searching based on id given in exception file: ') 327 | manga_search = AniList.search_for_manga_title_by_id(anilist_id, logging_info) 328 | elif isadult: # enable adult result in Anilist 329 | LOG.info('Adult result enabled') 330 | manga_search = AniList.search_for_manga_title_by_manga_title_with_adult(manga_title, format, logging_info) 331 | else: 332 | manga_search = AniList.search_for_manga_title_by_manga_title(manga_title, format, logging_info) 333 | if manga_search is None: 334 | raise MangaNotFoundError(manga_title) 335 | db_exists = False 336 | 337 | if db_exists: 338 | series_title = MetadataTable.search_series_title(manga_title) 339 | series_title_legal = slugify(series_title) 340 | manga_library_dir = Path(AppSettings.library_dir, series_title_legal) 341 | try: 342 | if volume is not None: 343 | new_filename = f"{series_title_legal} Vol.{volume} {manga_chapter_number}.cbz" 344 | else: 345 | new_filename = f"{series_title_legal} {manga_chapter_number}.cbz" 346 | LOG.debug(f'new_filename: {new_filename}') 347 | except TypeError: 348 | LOG.warning(f'Manga Tagger was unable to process "{file_path}"', extra=logging_info) 349 | return None 350 | new_file_path = Path(manga_library_dir, new_filename) 351 | 352 | if AppSettings.mode_settings is None or AppSettings.mode_settings['rename_file']: 353 | if not manga_library_dir.exists(): 354 | LOG.info( 355 | f'A directory for "{series_title}" in "{AppSettings.library_dir}" does not exist; creating now.') 356 | manga_library_dir.mkdir() 357 | try: 358 | # Multithreading Optimization 359 | if new_file_path in CURRENTLY_PENDING_RENAME: 360 | LOG.info(f'A file is currently being renamed under the filename "{new_filename}". Locking ' 361 | f'{file_path} from further processing until this rename action is complete...', 362 | extra=logging_info) 363 | 364 | while new_file_path in CURRENTLY_PENDING_RENAME: 365 | time.sleep(1) 366 | 367 | LOG.info(f'The file being renamed to "{new_file_path}" has been completed. Unlocking ' 368 | f'"{new_filename}" for file rename processing.', extra=logging_info) 369 | else: 370 | LOG.info(f'No files currently currently being processed under the filename ' 371 | f'"{new_filename}". Locking new filename for processing...', extra=logging_info) 372 | CURRENTLY_PENDING_RENAME.add(new_file_path) 373 | 374 | rename_action(file_path, new_file_path, series_title, manga_chapter_number, logging_info) 375 | except (FileExistsError, FileUpdateNotRequiredError, FileAlreadyProcessedError) as e: 376 | LOG.exception(e, extra=logging_info) 377 | CURRENTLY_PENDING_RENAME.remove(new_file_path) 378 | return 379 | 380 | if manga_title in ProcSeriesTable.processed_series: 381 | LOG.info(f'Found an entry in manga_metadata for "{manga_title}".', extra=logging_info) 382 | else: 383 | LOG.info(f'Found an entry in manga_metadata for "{manga_title}"; unlocking series for processing.', 384 | extra=logging_info) 385 | ProcSeriesTable.processed_series.add(manga_title) 386 | 387 | if AppSettings.image: 388 | if not Path(f'{AppSettings.image_dir}/{series_title}_cover.jpg').exists(): 389 | LOG.info(f'Image directory configured but cover not found. Send request to Anilist for necessary data.',extra=logging_info) 390 | manga_id = MetadataTable.search_by_series_title(series_title)['_id'] 391 | anilist_details = AniList.search_details_by_series_id(manga_id, format, logging_info) 392 | LOG.info('Downloading series cover image...', extra=logging_info) 393 | download_cover_image(series_title, anilist_details['coverImage']['extraLarge']) 394 | else: 395 | LOG.info('Series cover image already exist, not downloading.', extra=logging_info) 396 | else: 397 | LOG.info('Image flag not set, not downloading series cover image.', extra=logging_info) 398 | 399 | 400 | 401 | manga_metadata = Metadata(series_title, logging_info, details=manga_search) 402 | logging_info['metadata'] = manga_metadata.__dict__ 403 | else: 404 | 405 | anilist_titles = construct_anilist_titles(manga_search['title']) 406 | logging_info['anilist_titles'] = anilist_titles 407 | 408 | if not anilist_titles == 'None' or anilist_titles is not None: 409 | manga_found = True 410 | 411 | series_title = anilist_titles.get('romaji') 412 | series_title_legal = slugify(series_title) 413 | LOG.info(f'Manga title found for "{manga_title}" found as "{series_title}".', extra=logging_info) 414 | 415 | series_id = manga_search['id'] 416 | anilist_details = AniList.search_details_by_series_id(series_id, format, logging_info) 417 | LOG.debug(f'anilist_details: {anilist_details}') 418 | 419 | try: 420 | if volume is not None: 421 | new_filename = f"{series_title_legal} Vol.{volume} {manga_chapter_number}.cbz" 422 | else: 423 | new_filename = f"{series_title_legal} {manga_chapter_number}.cbz" 424 | LOG.debug(f'new_filename: {new_filename}') 425 | except TypeError: 426 | LOG.warning(f'Manga Tagger was unable to process "{file_path}"', extra=logging_info) 427 | return None 428 | 429 | manga_library_dir = Path(AppSettings.library_dir, series_title_legal) 430 | LOG.debug(f'Manga Library Directory: {manga_library_dir}') 431 | 432 | new_file_path = Path(manga_library_dir, new_filename) 433 | LOG.debug(f'new_file_path: {new_file_path}') 434 | 435 | LOG.info(f'Checking for current and previously processed files with filename "{new_filename}"...', 436 | extra=logging_info) 437 | 438 | if AppSettings.mode_settings is None or AppSettings.mode_settings['rename_file']: 439 | if not manga_library_dir.exists(): 440 | LOG.info( 441 | f'A directory for "{series_title}" in "{AppSettings.library_dir}" does not exist; creating now.') 442 | manga_library_dir.mkdir() 443 | try: 444 | # Multithreading Optimization 445 | if new_file_path in CURRENTLY_PENDING_RENAME: 446 | LOG.info(f'A file is currently being renamed under the filename "{new_filename}". Locking ' 447 | f'{file_path} from further processing until this rename action is complete...', 448 | extra=logging_info) 449 | 450 | while new_file_path in CURRENTLY_PENDING_RENAME: 451 | time.sleep(1) 452 | 453 | LOG.info(f'The file being renamed to "{new_file_path}" has been completed. Unlocking ' 454 | f'"{new_filename}" for file rename processing.', extra=logging_info) 455 | else: 456 | LOG.info(f'No files currently currently being processed under the filename ' 457 | f'"{new_filename}". Locking new filename for processing...', extra=logging_info) 458 | CURRENTLY_PENDING_RENAME.add(new_file_path) 459 | 460 | rename_action(file_path, new_file_path, series_title, manga_chapter_number, logging_info) 461 | 462 | except (FileExistsError, FileUpdateNotRequiredError, FileAlreadyProcessedError) as e: 463 | LOG.exception(e, extra=logging_info) 464 | CURRENTLY_PENDING_RENAME.remove(new_file_path) 465 | return 466 | 467 | manga_metadata = Metadata(series_title, logging_info, anilist_details) 468 | logging_info['metadata'] = manga_metadata.__dict__ 469 | 470 | if series_title in ProcSeriesTable.processed_series: 471 | LOG.info( 472 | f'Found an entry in manga_metadata for "{series_title}". Filename was probably not perfectly named according to MAL. Not adding metadata to MetadataTable.', 473 | extra=logging_info) 474 | else: 475 | if AppSettings.mode_settings is None or ('database_insert' in AppSettings.mode_settings.keys() 476 | and AppSettings.mode_settings['database_insert']): 477 | MetadataTable.insert(manga_metadata, logging_info) 478 | LOG.info(f'Retrieved metadata for "{series_title}" from the Anilist and MyAnimeList APIs; ' 479 | f'now unlocking series for processing!', extra=logging_info) 480 | ProcSeriesTable.processed_series.add(series_title) 481 | 482 | if AppSettings.mode_settings is None or ('write_comicinfo' in AppSettings.mode_settings.keys() 483 | and AppSettings.mode_settings['write_comicinfo']): 484 | if AppSettings.image: 485 | if not Path(f'{AppSettings.image_dir}/{series_title}_cover.jpg').exists(): 486 | LOG.info(f'Image directory configured but cover not found. Downloading series cover image...', extra=logging_info) 487 | download_cover_image(series_title, anilist_details['coverImage']['extraLarge']) 488 | else: 489 | LOG.info('Series cover image already exist, not downloading.', extra=logging_info) 490 | comicinfo_xml = construct_comicinfo_xml(manga_metadata, manga_chapter_number, logging_info, volume) 491 | reconstruct_manga_chapter(series_title, comicinfo_xml, new_file_path, logging_info) 492 | if AppSettings.image and (not AppSettings.image_first or (AppSettings.image_first and int(float(manga_chapter_number))==1)): 493 | add_cover_to_manga_chapter(series_title, new_file_path, logging_info) 494 | 495 | LOG.info(f'Processing on "{new_file_path}" has finished.', extra=logging_info) 496 | return manga_metadata 497 | 498 | 499 | def construct_anilist_titles(anilist_details): 500 | anilist_titles = {} 501 | 502 | if anilist_details['romaji'] is not None: 503 | anilist_titles['romaji'] = anilist_details['romaji'] 504 | 505 | if anilist_details['english'] is not None: 506 | anilist_titles['english'] = anilist_details['english'] 507 | 508 | if anilist_details['native'] is not None: 509 | anilist_titles['native'] = anilist_details['native'] 510 | 511 | return anilist_titles 512 | 513 | 514 | def construct_comicinfo_xml(metadata: Metadata, chapter_number, logging_info, volume_number): 515 | LOG.info(f'Constructing comicinfo object for "{metadata.series_title}", chapter {chapter_number}...', 516 | extra=logging_info) 517 | 518 | comicinfo = Element('ComicInfo') 519 | 520 | application_tag = Comment('Generated by Manga Tagger, an Endless Galaxy Studios project') 521 | comicinfo.append(application_tag) 522 | 523 | series = SubElement(comicinfo, 'Series') 524 | series.text = metadata.series_title 525 | 526 | if metadata.series_title_eng is not None and compare(metadata.series_title, 527 | metadata.series_title_eng) != 1 and metadata.series_title_eng != "": 528 | localized_series = SubElement(comicinfo, 'LocalizedSeries') 529 | localized_series.text = metadata.series_title_eng 530 | 531 | number = SubElement(comicinfo, 'Number') 532 | number.text = f'{chapter_number}' 533 | if volume_number is not None: 534 | volume = SubElement(comicinfo, 'Volume') 535 | volume.text = f'{volume_number}' 536 | 537 | if metadata.volumes is not None: 538 | count = SubElement(comicinfo,"Count") 539 | count.text = f'{metadata.volumes}' 540 | 541 | summary = SubElement(comicinfo, 'Summary') 542 | soup = BeautifulSoup(metadata.description, "html.parser") 543 | summary.text = soup.get_text() 544 | 545 | publish_date = datetime.strptime(metadata.publish_date, '%Y-%m-%d').date() 546 | year = SubElement(comicinfo, 'Year') 547 | year.text = f'{publish_date.year}' 548 | 549 | month = SubElement(comicinfo, 'Month') 550 | month.text = f'{publish_date.month}' 551 | 552 | writer = SubElement(comicinfo, 'Writer') 553 | writer.text = next(iter(metadata.staff['story'])) 554 | 555 | penciller = SubElement(comicinfo, 'Penciller') 556 | penciller.text = next(iter(metadata.staff['art'])) 557 | 558 | inker = SubElement(comicinfo, 'Inker') 559 | inker.text = next(iter(metadata.staff['art'])) 560 | 561 | colorist = SubElement(comicinfo, 'Colorist') 562 | colorist.text = next(iter(metadata.staff['art'])) 563 | 564 | letterer = SubElement(comicinfo, 'Letterer') 565 | letterer.text = next(iter(metadata.staff['art'])) 566 | 567 | cover_artist = SubElement(comicinfo, 'CoverArtist') 568 | cover_artist.text = next(iter(metadata.staff['art'])) 569 | 570 | # publisher = SubElement(comicinfo, 'Publisher') 571 | # publisher.text = next(iter(metadata.serializations)) 572 | 573 | genre = SubElement(comicinfo, 'Genre') 574 | for mg in metadata.genres: 575 | if genre.text is not None: 576 | genre.text += f',{mg}' 577 | else: 578 | genre.text = f'{mg}' 579 | 580 | web = SubElement(comicinfo, 'Web') 581 | web.text = metadata.anilist_url 582 | 583 | language = SubElement(comicinfo, 'LanguageISO') 584 | language.text = 'en' 585 | 586 | manga = SubElement(comicinfo, 'Manga') 587 | manga.text = 'Yes' 588 | 589 | notes = SubElement(comicinfo, 'Notes') 590 | notes.text = f'Scraped metadata from AniList on {metadata.scrape_date}' 591 | 592 | comicinfo.set('xmlns:xsd', 'http://www.w3.org/2001/XMLSchema') 593 | comicinfo.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance') 594 | 595 | LOG.info(f'Finished creating ComicInfo object for "{metadata.series_title}", chapter {chapter_number}.', 596 | extra=logging_info) 597 | return parseString(tostring(comicinfo)).toprettyxml(indent=" ") 598 | 599 | 600 | def reconstruct_manga_chapter(manga_title, comicinfo_xml, manga_file_path, logging_info): 601 | try: 602 | with ZipFile(manga_file_path, 'a') as zipfile: 603 | zipfile.writestr('ComicInfo.xml', comicinfo_xml) 604 | except Exception as e: 605 | LOG.exception(e, extra=logging_info) 606 | LOG.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 607 | extra=logging_info) 608 | return 609 | LOG.info(f'ComicInfo.xml has been created and appended to "{manga_file_path}".', extra=logging_info) 610 | 611 | def add_cover_to_manga_chapter(manga_title, manga_file_path, logging_info): 612 | try: 613 | with ZipFile(manga_file_path, 'a') as zipfile: 614 | if AppSettings.image and Path(f'{AppSettings.image_dir}/{manga_title}_cover.jpg').exists(): 615 | zipfile.write(f'{AppSettings.image_dir}/{manga_title}_cover.jpg', '000_cover.jpg') 616 | except Exception as e: 617 | LOG.exception(e, extra=logging_info) 618 | LOG.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 619 | extra=logging_info) 620 | return 621 | LOG.info(f'Cover Image has been added to "{manga_file_path}".', extra=logging_info) 622 | 623 | def download_cover_image(manga_title, image_url): 624 | image = requests.get(image_url) 625 | with open(f'{AppSettings.image_dir}/{manga_title}_cover.jpg', 'wb') as image_file: 626 | image_file.write(image.content) 627 | 628 | 629 | def slugify(value, allow_unicode=False): 630 | """ 631 | Taken from https://github.com/django/django/blob/master/django/utils/text.py 632 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated 633 | dashes to single dashes. Remove characters that aren't alphanumerics, 634 | underscores, or hyphens. Convert to lowercase. Also strip leading and 635 | trailing whitespace, dashes, and underscores. 636 | """ 637 | value = str(value) 638 | if allow_unicode: 639 | value = unicodedata.normalize('NFKC', value) 640 | else: 641 | value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') 642 | value = re.sub(r'[^\w\s-]', '', value) 643 | value = value.strip() 644 | return value 645 | 646 | 647 | def hasNumbers(inputString): 648 | return bool(re.search(r'\d', inputString)) 649 | -------------------------------------------------------------------------------- /MangaTaggerLib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MangaManagerORG/Manga-Tagger/132379ad6e8c9057ed8c079d16838b2e74f5e420/MangaTaggerLib/__init__.py -------------------------------------------------------------------------------- /MangaTaggerLib/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.2.3-alpha' 2 | -------------------------------------------------------------------------------- /MangaTaggerLib/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import time 4 | from datetime import datetime 5 | from typing import Optional, Dict, Mapping, Union, Any 6 | 7 | class AniList: 8 | _log = None 9 | 10 | @classmethod 11 | def initialize(cls): 12 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 13 | 14 | @classmethod 15 | def _post(cls, query, variables, logging_info): 16 | try: 17 | response = requests.post('https://graphql.anilist.co', json={'query': query, 'variables': variables}) 18 | if response.status_code == 429: # Anilist rate-limit code 19 | raise AniListRateLimit() 20 | except Exception as e: 21 | cls._log.exception(e, extra=logging_info) 22 | cls._log.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 23 | extra=logging_info) 24 | return None 25 | 26 | cls._log.debug(f'Query: {query}') 27 | cls._log.debug(f'Variables: {variables}') 28 | cls._log.debug(f'Response JSON: {response.json()}') 29 | try: 30 | return response.json()['data']['Media'] 31 | except TypeError: 32 | return None 33 | 34 | @classmethod 35 | def search_for_manga_title_by_id(cls, manga_id, logging_info): 36 | query = ''' 37 | query search_for_manga_title_by_id ($manga_id: Int) { 38 | Media (id: $manga_id, type: MANGA) { 39 | id 40 | title { 41 | romaji 42 | english 43 | native 44 | } 45 | synonyms 46 | } 47 | } 48 | ''' 49 | 50 | variables = { 51 | 'manga_id': manga_id, 52 | } 53 | 54 | return cls._post(query, variables, logging_info) 55 | 56 | @classmethod 57 | def search_for_manga_title_by_manga_title(cls, manga_title, format, logging_info): 58 | query = ''' 59 | query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) { 60 | Media (search: $manga_title, type: MANGA, format: $format, isAdult: false) { 61 | id 62 | title { 63 | romaji 64 | english 65 | native 66 | } 67 | synonyms 68 | } 69 | } 70 | ''' 71 | 72 | variables = { 73 | 'manga_title': manga_title, 74 | 'format': format 75 | } 76 | 77 | return cls._post(query, variables, logging_info) 78 | 79 | @classmethod 80 | def search_for_manga_title_by_manga_title_with_adult(cls, manga_title, format, logging_info): 81 | query = ''' 82 | query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) { 83 | Media (search: $manga_title, type: MANGA, format: $format) { 84 | id 85 | title { 86 | romaji 87 | english 88 | native 89 | } 90 | synonyms 91 | } 92 | } 93 | ''' 94 | 95 | variables = { 96 | 'manga_title': manga_title, 97 | 'format': format 98 | } 99 | 100 | return cls._post(query, variables, logging_info) 101 | 102 | @classmethod 103 | def search_details_by_series_id(cls, series_id, format, logging_info): 104 | query = ''' 105 | query search_details_by_series_id ($series_id: Int, $format: MediaFormat) { 106 | Media (id: $series_id, type: MANGA, format: $format) { 107 | id 108 | status 109 | volumes 110 | siteUrl 111 | title { 112 | romaji 113 | english 114 | native 115 | } 116 | type 117 | genres 118 | synonyms 119 | startDate { 120 | day 121 | month 122 | year 123 | } 124 | coverImage { 125 | extraLarge 126 | } 127 | staff { 128 | edges { 129 | node{ 130 | name { 131 | first 132 | last 133 | full 134 | alternative 135 | } 136 | siteUrl 137 | } 138 | role 139 | } 140 | } 141 | description 142 | } 143 | } 144 | ''' 145 | 146 | variables = { 147 | 'series_id': series_id, 148 | 'format': format 149 | } 150 | 151 | return cls._post(query, variables, logging_info) 152 | 153 | 154 | class AniListRateLimit(Exception): 155 | """ 156 | Exception raised when AniList rate-limit is breached. 157 | """ -------------------------------------------------------------------------------- /MangaTaggerLib/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from datetime import datetime 4 | from pathlib import Path 5 | from queue import Queue 6 | 7 | from bson.errors import InvalidDocument 8 | from pymongo import MongoClient 9 | from pymongo.errors import ServerSelectionTimeoutError, DuplicateKeyError 10 | 11 | 12 | class Database: 13 | database_name = None 14 | host_address = None 15 | port = None 16 | username = None 17 | password = None 18 | auth_source = None 19 | server_selection_timeout_ms = None 20 | 21 | _client = None 22 | _database = None 23 | _log = None 24 | 25 | @classmethod 26 | def initialize(cls): 27 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 28 | 29 | if cls.auth_source is None: 30 | cls._client = MongoClient(cls.host_address, 31 | cls.port, 32 | username=cls.username, 33 | password=cls.password, 34 | serverSelectionTimeoutMS=cls.server_selection_timeout_ms) 35 | else: 36 | cls._client = MongoClient(cls.host_address, 37 | cls.port, 38 | username=cls.username, 39 | password=cls.password, 40 | authSource=cls.auth_source, 41 | serverSelectionTimeoutMS=cls.server_selection_timeout_ms) 42 | 43 | try: 44 | cls._log.info('Establishing database connection...') 45 | cls._client.is_mongos 46 | except ServerSelectionTimeoutError as sste: 47 | cls._log.exception(sste) 48 | cls._log.critical('Manga Tagger cannot run without a database connection. Please check the' 49 | 'configuration in settings.json and try again.') 50 | sys.exit(1) 51 | 52 | cls._database = cls._client[cls.database_name] 53 | 54 | MetadataTable.initialize() 55 | ProcFilesTable.initialize() 56 | ProcSeriesTable.initialize() 57 | TaskQueueTable.initialize() 58 | 59 | cls._log.info('Database connection established!') 60 | cls._log.debug(f'{cls.__name__} class has been initialized') 61 | 62 | @classmethod 63 | def load_database_tables(cls): 64 | ProcSeriesTable.load() 65 | 66 | @classmethod 67 | def save_database_tables(cls): 68 | ProcSeriesTable.save() 69 | 70 | @classmethod 71 | def close_connection(cls): 72 | cls._log.info('Closing database connection...') 73 | cls._client.close() 74 | 75 | @classmethod 76 | def print_debug_settings(cls): 77 | cls._log.debug(f'Database Name: {Database.database_name}') 78 | cls._log.debug(f'Host Address: {Database.host_address}') 79 | cls._log.debug(f'Port: {Database.port}') 80 | cls._log.debug(f'Username: {Database.username}') 81 | cls._log.debug(f'Password: {Database.password}') 82 | cls._log.debug(f'Authentication Source: {Database.auth_source}') 83 | cls._log.debug(f'Server Selection Timeout (ms): {Database.server_selection_timeout_ms}') 84 | 85 | @classmethod 86 | def insert(cls, data, logging_info=None): 87 | try: 88 | cls._log.info('Attempting to insert record into the database...', extra=logging_info) 89 | 90 | if type(data) is dict: 91 | cls._database.insert_one(data) 92 | else: 93 | cls._database.insert_one(data.__dict__) 94 | except (DuplicateKeyError, InvalidDocument) as e: 95 | cls._log.exception(e, extra=logging_info) 96 | return 97 | except Exception as e: 98 | cls._log.exception(e, extra=logging_info) 99 | cls._log.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 100 | extra=logging_info) 101 | return 102 | 103 | cls._log.info('Insertion was successful!', extra=logging_info) 104 | 105 | @classmethod 106 | def update(cls, search_filter, data, logging_info): 107 | try: 108 | cls._log.info('Attempting to update record in the database...', extra=logging_info) 109 | cls._database.update_one(search_filter, data) 110 | except Exception as e: 111 | cls._log.exception(e, extra=logging_info) 112 | cls._log.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 113 | extra=logging_info) 114 | return 115 | 116 | cls._log.info('Update was successful!', extra=logging_info) 117 | 118 | @classmethod 119 | def delete_all(cls, logging_info): 120 | try: 121 | cls._log.info('Attempting to delete all records in the database...', extra=logging_info) 122 | cls._database.delete_many({}) 123 | except Exception as e: 124 | cls._log.exception(e, extra=logging_info) 125 | cls._log.warning('Manga Tagger is unfamiliar with this error. Please log an issue for investigation.', 126 | extra=logging_info) 127 | return 128 | 129 | cls._log.info('Deletion was successful!', extra=logging_info) 130 | 131 | 132 | class MetadataTable(Database): 133 | @classmethod 134 | def initialize(cls): 135 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 136 | cls._database = super()._database['manga_metadata'] 137 | cls._log.debug(f'{cls.__name__} class has been initialized') 138 | 139 | @classmethod 140 | def search_by_search_id(cls, manga_id): 141 | cls._log.debug(f'Searching manga_metadata cls by key "_id" using value "{manga_id}"') 142 | return cls._database.find_one({ 143 | '_id': manga_id 144 | }) 145 | 146 | @classmethod 147 | def search_by_search_value(cls, manga_title): 148 | cls._log.debug(f'Searching manga_metadata cls by key "search_value" using value "{manga_title}"') 149 | return cls._database.find_one({'$or': [ 150 | {'search_value': manga_title}, 151 | {'series_title': manga_title}, 152 | {'series_title_eng': manga_title}, 153 | {'series_title_jap': manga_title}, 154 | {'synonyms': manga_title} 155 | ]}) 156 | 157 | @classmethod 158 | def search_id_by_search_value(cls, manga_title): 159 | cls._log.debug(f'Searching "series_id" using value "{manga_title}"') 160 | cursor = cls._database.find_one({"search_value": manga_title}, {"_id": 1}) 161 | return cursor['_id'] 162 | 163 | @classmethod 164 | def search_series_title(cls, manga_title): 165 | cls._log.debug(f'Searching "series_title" using value "{manga_title}"') 166 | return cls._database.find_one({"$or": [ 167 | {'search_value': manga_title}, 168 | {'series_title': manga_title}, 169 | {'series_title_eng': manga_title}, 170 | {'series_title_jap': manga_title}, 171 | {'synonyms': manga_title} 172 | ]}, {'series_title': 1})['series_title'] 173 | 174 | 175 | class ProcFilesTable(Database): 176 | @classmethod 177 | def initialize(cls): 178 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 179 | cls._database = super()._database['processed_files'] 180 | cls._log.debug(f'{cls.__name__} class has been initialized') 181 | 182 | @classmethod 183 | def search(cls, manga_title, chapter_number): 184 | cls._log.debug(f'Searching processed_files cls by keys "series_title" and "chapter_number" ' 185 | f'using values "{manga_title}" and {chapter_number}') 186 | return cls._database.find_one({ 187 | 'series_title': manga_title, 188 | 'chapter_number': chapter_number 189 | }) 190 | 191 | @classmethod 192 | def insert_record(cls, old_file_path: Path, new_file_path: Path, manga_title, chapter, logging_info): 193 | record = { 194 | "series_title": manga_title, 195 | "chapter_number": chapter, 196 | "old_filename": old_file_path.name, 197 | "new_filename": new_file_path.name, 198 | "process_date": datetime.now().date().strftime('%Y-%m-%d @ %I:%M:%S %p') 199 | } 200 | 201 | cls._log.debug(f'Record: {record}') 202 | 203 | logging_info['inserted_processed_record'] = record 204 | cls._database.insert(record, logging_info) 205 | 206 | @classmethod 207 | def update_record(cls, results, old_file_path: Path, new_file_path: Path, logging_info): 208 | record = { 209 | "$set": { 210 | "old_filename": old_file_path.name, 211 | "update_date": datetime.now().date().strftime('%Y-%m-%d @ %I:%M:%S %p') 212 | } 213 | } 214 | cls._log.debug(f'Record: {record}') 215 | 216 | logging_info['updated_processed_record'] = record 217 | cls._database.update(results, record, logging_info) 218 | 219 | 220 | class ProcSeriesTable(Database): 221 | processed_series = set() 222 | 223 | @classmethod 224 | def initialize(cls): 225 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 226 | cls._database = super()._database['processed_series'] 227 | cls._id = None 228 | cls._last_save_time = None 229 | cls._log.debug(f'{cls.__name__} class has been initialized') 230 | 231 | @classmethod 232 | def save(cls): 233 | cls._log.info('Saving processed series...') 234 | cls._database.delete_one({ 235 | '_id': cls._id 236 | }) 237 | super(ProcSeriesTable, cls).insert(dict.fromkeys(cls.processed_series, True)) 238 | 239 | @classmethod 240 | def load(cls): 241 | cls._log.info('Loading processed series...') 242 | results = cls._database.find_one() 243 | if results is not None: 244 | cls._id = results.pop('_id') 245 | cls.processed_series = set(results.keys()) 246 | 247 | @classmethod 248 | def save_while_running(cls): 249 | if cls._last_save_time is not None: 250 | last_save_delta = (datetime.now() - cls._last_save_time).total_seconds() 251 | 252 | # Save every hour 253 | if last_save_delta > 3600: 254 | cls._last_save_time = datetime.now() 255 | cls.save() 256 | 257 | 258 | class TaskQueueTable(Database): 259 | @classmethod 260 | def initialize(cls): 261 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 262 | cls._database = super()._database['task_queue'] 263 | cls.queue = Queue() 264 | cls._log.debug(f'{cls.__name__} class has been initialized') 265 | 266 | @classmethod 267 | def load(cls, task_list: dict): 268 | cls._log.info('Loading task queue...') 269 | results = cls._database.find() 270 | 271 | if results is not None: 272 | for result in results: 273 | task_list[result['manga_chapter']] = result 274 | 275 | @classmethod 276 | def save(cls, queue): 277 | if not queue.empty(): 278 | cls._log.info('Saving task queue...') 279 | while not queue.empty(): 280 | event = queue.get() 281 | super(TaskQueueTable, cls).insert(event.dictionary()) 282 | 283 | @classmethod 284 | def delete_all(cls): 285 | super(TaskQueueTable, cls).delete_all(None) 286 | -------------------------------------------------------------------------------- /MangaTaggerLib/errors.py: -------------------------------------------------------------------------------- 1 | """Exceptions raised by Manga Tagger""" 2 | 3 | class MangaNotFoundError(Exception): 4 | """ 5 | Exception raised when the manga cannot be found in the results from MyAnimeList (Jikan). 6 | """ 7 | def __init__(self, manga_title): 8 | super().__init__(f'"{manga_title}" was not found in the returned results from Anilist ' 9 | f'This may be due to a difference in manga series titles, or may be something else entirely. ' 10 | f'Please open an issue for investigation.') 11 | 12 | class MetadataNotCompleteError(Exception): 13 | """ 14 | Exception raised when not enough data is given to create a Metadata object. 15 | """ 16 | 17 | def __init__(self, current_directory): 18 | super().__init__('Tried to create Metadata object, but was not given the proper data to interpret') 19 | 20 | 21 | class UnparsableFilenameError(Exception): 22 | """ 23 | Exception raised when the chapter filename is unparsable; specifically when 'chapter' is not found in the filename. 24 | 25 | Attributes: 26 | filename - Name of the file 27 | delimiter_key - The sequence of characters not found that triggered the error 28 | """ 29 | 30 | def __init__(self, filename, delimiter_key): 31 | super().__init__(f'Unable to parse filename "{filename}" due to delimiter "{delimiter_key}" missing') 32 | 33 | 34 | class FileAlreadyProcessedError(FileExistsError): 35 | """ 36 | Exception raised when the chapter currently being processed has already been processed and the filename does not 37 | indicate a different version from what was previously processed. 38 | 39 | Attributes: 40 | filename - Name of the file 41 | """ 42 | def __init__(self, filename): 43 | super().__init__(f'"{filename}" has already been processed by Manga Tagger; skipping') 44 | 45 | 46 | class FileUpdateNotRequiredError(FileExistsError): 47 | """ 48 | Exception raised when the chapter currently being processed has been found to be older than the chapter that was 49 | previously processed. 50 | 51 | Attributes: 52 | filename - Name of the file 53 | """ 54 | def __init__(self, filename): 55 | super().__init__(f'"{filename}" is older than or the same as the current existing chapter; skipping') 56 | 57 | 58 | class MangaMatchedException(Exception): 59 | """ 60 | Exception raised to bypass try...except when comparing manga titles. 61 | """ -------------------------------------------------------------------------------- /MangaTaggerLib/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from pytz import timezone 4 | 5 | from MangaTaggerLib.errors import MetadataNotCompleteError 6 | from MangaTaggerLib.utils import AppSettings, compare 7 | 8 | 9 | class Metadata: 10 | _log = None 11 | 12 | @classmethod 13 | def fully_qualified_class_name(cls): 14 | return f'{cls.__module__}.{cls.__name__}' 15 | 16 | def __init__(self, manga_title, logging_info, anilist_details=None, details=None): 17 | Metadata._log = logging.getLogger(self.fully_qualified_class_name()) 18 | 19 | self.search_value = manga_title 20 | Metadata._log.info(f'Creating Metadata model for series "{manga_title}"...', extra=logging_info) 21 | 22 | if anilist_details: # If details are grabbed from Anilist APIs 23 | self._construct_api_metadata(anilist_details, logging_info) 24 | elif details: # If details were stored in the database 25 | self._construct_database_metadata(details) 26 | else: 27 | Metadata._log.exception(MetadataNotCompleteError, extra=logging_info) 28 | Metadata._log.debug(f'{self.search_value} Metadata Model: {self.__dict__.__str__()}') 29 | 30 | logging_info['metadata'] = self.__dict__ 31 | Metadata._log.info('Successfully created Metadata model.', extra=logging_info) 32 | 33 | def _construct_api_metadata(self, anilist_details, logging_info): 34 | self._id = anilist_details['id'] 35 | self.series_title = anilist_details['title']['romaji'] 36 | 37 | if anilist_details['title']['english'] == 'None' or anilist_details['title']['english'] is None: 38 | self.series_title_eng = None 39 | else: 40 | self.series_title_eng = anilist_details['title']['english'] 41 | 42 | if anilist_details['title']['native'] == 'None' or anilist_details['title']['native'] is None: 43 | self.series_title_jap = None 44 | else: 45 | self.series_title_jap = anilist_details['title']['native'] 46 | 47 | self.status = anilist_details['status'] 48 | if anilist_details.get('volumes'): 49 | self.volumes = anilist_details.get('volumes') 50 | else: 51 | self.volumes = None 52 | 53 | self.type = anilist_details['type'] 54 | self.description = anilist_details['description'] 55 | self.anilist_url = anilist_details['siteUrl'] 56 | self.publish_date = None 57 | self.genres = [] 58 | self.synonyms = [] 59 | self.staff = {} 60 | 61 | self._construct_publish_date(anilist_details['startDate']) 62 | self._parse_genres(anilist_details['genres'], logging_info) 63 | self._parse_synonyms(anilist_details.get('synonyms'), logging_info) 64 | self._parse_staff(anilist_details['staff']['edges'], logging_info) 65 | 66 | self.scrape_date = timezone(AppSettings.timezone).localize(datetime.now()).strftime('%Y-%m-%d %I:%M %p %Z') 67 | 68 | def _construct_database_metadata(self, details): 69 | self._id = details['_id'] 70 | self.series_title = details['series_title'] 71 | self.series_title_eng = details['series_title_eng'] 72 | self.series_title_jap = details['series_title_jap'] 73 | self.status = details['status'] 74 | self.volumes = details.get("volumes") 75 | self.type = details['type'] 76 | self.description = details['description'] 77 | self.anilist_url = details['anilist_url'] 78 | self.publish_date = details['publish_date'] 79 | self.genres = details['genres'] 80 | self.synonyms = details['synonyms'] 81 | self.staff = details['staff'] 82 | self.publish_date = details['publish_date'] 83 | self.scrape_date = details['scrape_date'] 84 | 85 | def _construct_publish_date(self, date): 86 | if date['month'] == 'None' or date['day'] == 'None' or date['day'] is None or date['month'] is None: 87 | yearstr = str(date.get('year')) 88 | datestr = yearstr + "-" + "01" + "-" "01" 89 | else: 90 | datestr = str(date.get('year')) + "-" + str(date.get('month')) + "-" + str(date.get('day')) 91 | self.publish_date = datetime.strptime(datestr, '%Y-%m-%d').strftime('%Y-%m-%d') 92 | Metadata._log.debug(f'Publish date constructed: {self.publish_date}') 93 | 94 | def _parse_genres(self, genres, logging_info): 95 | Metadata._log.info('Parsing genres...', extra=logging_info) 96 | Metadata._log.debug(f'Genre found: {", ".join(genres)}') 97 | self.genres.extend(genres) 98 | 99 | def _parse_synonyms(self, synonyms, logging_info): 100 | Metadata._log.info('Parsing Synonyms...', extra=logging_info) 101 | Metadata._log.debug(f'Synonyms found: {synonyms}') 102 | self.synonyms.extend(synonyms or []) 103 | 104 | def _parse_staff(self, anilist_staff, logging_info): 105 | Metadata._log.info('Parsing staff roles...', extra=logging_info) 106 | 107 | roles = [] 108 | 109 | self.staff = { 110 | 'story': {}, 111 | 'art': {} 112 | } 113 | 114 | for a_staff in anilist_staff: 115 | Metadata._log.debug(f'Staff Member (Anilist): {a_staff}') 116 | 117 | anilist_staff_name = '' 118 | 119 | if a_staff['node']['name']['last'] is not None: 120 | anilist_staff_name = a_staff['node']['name']['last'] 121 | 122 | if a_staff['node']['name']['first'] is not None: 123 | anilist_staff_name += ', ' + a_staff['node']['name']['first'] 124 | 125 | names_to_compare = [anilist_staff_name] 126 | if '' not in a_staff['node']['name']['alternative']: 127 | for name in a_staff['node']['name']['alternative']: 128 | names_to_compare.append(name) 129 | 130 | role = a_staff['role'].lower() 131 | if 'story & art' in role: 132 | roles.append('story') 133 | roles.append('art') 134 | self._add_anilist_staff_member('story', a_staff) 135 | self._add_anilist_staff_member('art', a_staff) 136 | elif 'story' in role: 137 | roles.append('story') 138 | self._add_anilist_staff_member('story', a_staff) 139 | elif 'art' in role: 140 | roles.append('art') 141 | self._add_anilist_staff_member('art', a_staff) 142 | else: 143 | Metadata._log.warning(f'Expected role not found for staff member "{a_staff}"; instead' 144 | f' found "{role}"', extra=logging_info) 145 | 146 | # Validate expected roles for staff members 147 | role_set = ['story', 'art'] 148 | 149 | if set(roles) != set(role_set): 150 | 151 | Metadata._log.warning(f'Not all expected roles are present for series "{self.search_value}"; ' 152 | f'double check ID "{self._id}"', extra=logging_info) 153 | 154 | def _add_anilist_staff_member(self, role, a_staff): 155 | self.staff[role][a_staff['node']['name']['full']] = { 156 | 'first_name': a_staff['node']['name']['first'], 157 | 'last_name': a_staff['node']['name']['last'], 158 | 'anilist_url': a_staff['node']['siteUrl'], 159 | } 160 | 161 | def _parse_serializations(self, serializations, logging_info): 162 | Metadata._log.info('Parsing serializations...', extra=logging_info) 163 | for serialization in serializations: 164 | Metadata._log.debug(serialization) 165 | self.serializations[serialization['name'].strip('.')] = { 166 | 'mal_id': serialization['mal_id'], 167 | 'url': serialization['url'] 168 | } 169 | 170 | def test_value(self): 171 | return { 172 | 'series_title': self.series_title, 173 | 'series_title_eng': self.series_title_eng, 174 | 'series_title_jap': self.series_title_jap, 175 | 'status': self.status, 176 | 'volumes':self.volumes, 177 | # 'mal_url': self.mal_url, 178 | 'anilist_url': self.anilist_url, 179 | 'publish_date': self.publish_date, 180 | 'genres': self.genres, 181 | 'synonyms': self.synonyms, 182 | 'staff': self.staff, 183 | # 'serializations': self.serializations 184 | } 185 | -------------------------------------------------------------------------------- /MangaTaggerLib/task_queue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import uuid 4 | from enum import Enum 5 | from pathlib import Path 6 | from queue import Queue 7 | from threading import Thread 8 | from typing import List 9 | 10 | from watchdog.events import PatternMatchingEventHandler 11 | from watchdog.observers import Observer 12 | from watchdog.observers.polling import PollingObserver 13 | 14 | from MangaTaggerLib import MangaTaggerLib 15 | from MangaTaggerLib.database import TaskQueueTable 16 | 17 | 18 | class QueueEventOrigin(Enum): 19 | WATCHDOG = 1 20 | FROM_DB = 2 21 | SCAN = 3 22 | 23 | 24 | class QueueEvent: 25 | def __init__(self, event, origin=QueueEventOrigin.WATCHDOG): 26 | if origin == QueueEventOrigin.WATCHDOG: 27 | self.event_type = event.event_type 28 | self.src_path = Path(event.src_path) 29 | try: 30 | self.dest_path = Path(event.dest_path) 31 | except AttributeError: 32 | pass 33 | elif origin == QueueEventOrigin.FROM_DB: 34 | self.event_type = event['event_type'] 35 | self.src_path = Path(event['src_path']) 36 | try: 37 | self.dest_path = Path(event['dest_path']) 38 | except KeyError: 39 | pass 40 | elif origin == QueueEventOrigin.SCAN: 41 | self.event_type = 'existing' 42 | self.src_path = event 43 | 44 | def __str__(self): 45 | if self.event_type in ('created', 'existing'): 46 | return f'File {self.event_type} event at {self.src_path.absolute()}' 47 | elif self.event_type == 'modified': 48 | return f'File {self.event_type} event at {self.dest_path.absolute()}' 49 | 50 | def dictionary(self): 51 | ret_dict = { 52 | 'event_type': self.event_type, 53 | 'src_path': str(self.src_path.absolute()), 54 | 'manga_chapter': str(self.src_path.name.strip('.cbz')) 55 | } 56 | 57 | try: 58 | ret_dict['dest_path'] = str(self.dest_path.absolute()) 59 | except AttributeError: 60 | pass 61 | 62 | return ret_dict 63 | 64 | 65 | class QueueWorker: 66 | _queue: Queue = None 67 | _observer: Observer = None 68 | _log: logging = None 69 | _worker_list: List[Thread] = None 70 | _running: bool = False 71 | _debug_mode = False 72 | 73 | max_queue_size = None 74 | threads = None 75 | is_library_network_path = False 76 | download_dir: Path = None 77 | task_list = {} 78 | 79 | @classmethod 80 | def initialize(cls): 81 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 82 | cls._queue = Queue(maxsize=cls.max_queue_size) 83 | cls._worker_list = [] 84 | cls._running = True 85 | 86 | for i in range(cls.threads): 87 | if not cls._debug_mode: 88 | worker = Thread(target=cls.process, name=f'MTT-{i}', daemon=True) 89 | else: 90 | worker = Thread(target=cls.dummy_process, name=f'MTT-{i}', daemon=True) 91 | cls._log.debug(f'Worker thread {worker.name} has been initialized') 92 | cls._worker_list.append(worker) 93 | 94 | if cls.is_library_network_path: 95 | cls._observer = PollingObserver() 96 | else: 97 | cls._observer = Observer() 98 | 99 | cls._observer.schedule(SeriesHandler(cls._queue), cls.download_dir, True) 100 | 101 | @classmethod 102 | def load_task_queue(cls): 103 | TaskQueueTable.load(cls.task_list) 104 | 105 | for task in cls.task_list.values(): 106 | event = QueueEvent(task, QueueEventOrigin.FROM_DB) 107 | cls._log.info(f'{event} has been added to the task queue') 108 | cls._queue.put(event) 109 | 110 | TaskQueueTable.delete_all() 111 | 112 | @classmethod 113 | def save_task_queue(cls): 114 | TaskQueueTable.save(cls._queue) 115 | with cls._queue.mutex: 116 | cls._queue.queue.clear() 117 | 118 | @classmethod 119 | def add_to_task_queue(cls, manga_chapter): 120 | event = QueueEvent(manga_chapter, QueueEventOrigin.SCAN) 121 | cls._log.info(f'{event} has been added to the task queue') 122 | cls._queue.put(event) 123 | 124 | @classmethod 125 | def exit(cls): 126 | # Stop worker threads from picking new items from the queue in process() 127 | cls._log.info('Stopping processing...') 128 | cls._running = False 129 | 130 | # Stop watchdog from adding new events to the queue 131 | cls._log.debug('Stopping watchdog...') 132 | cls._observer.stop() 133 | cls._observer.join() 134 | 135 | # Save and empty task queue 136 | cls.save_task_queue() 137 | 138 | # Finish current running jobs and stop worker threads 139 | cls._log.info('Stopping worker threads...') 140 | for worker in cls._worker_list: 141 | worker.join() 142 | cls._log.debug(f'Worker thread {worker.name} has been shut down') 143 | 144 | @classmethod 145 | def run(cls): 146 | for worker in cls._worker_list: 147 | worker.start() 148 | 149 | cls._observer.start() 150 | 151 | cls._log.info(f'Watching "{cls.download_dir}" for new downloads') 152 | 153 | while cls._running: 154 | time.sleep(1) 155 | 156 | @classmethod 157 | def dummy_process(cls): 158 | pass 159 | 160 | @classmethod 161 | def process(cls): 162 | while cls._running: 163 | if not cls._queue.empty(): 164 | event = cls._queue.get() 165 | 166 | if event.event_type in ('created', 'existing'): 167 | cls._log.info(f'Pulling "file {event.event_type}" event from the queue for "{event.src_path}"') 168 | path = Path(event.src_path) 169 | elif event.event_type == 'moved': 170 | cls._log.info(f'Pulling "file {event.event_type}" event from the queue for "{event.dest_path}"') 171 | path = Path(event.dest_path) 172 | else: 173 | cls._log.error('Event was passed, but Manga Tagger does not know how to handle it. Please open an ' 174 | 'issue for further investigation.') 175 | cls._queue.task_done() 176 | return 177 | 178 | current_size = -1 179 | try: 180 | destination_size = path.stat().st_size 181 | while current_size != destination_size: 182 | current_size = destination_size 183 | time.sleep(1) 184 | except FileNotFoundError as fnfe: 185 | cls._log.exception(fnfe) 186 | 187 | try: 188 | MangaTaggerLib.process_manga_chapter(path, uuid.uuid1()) 189 | except Exception as e: 190 | cls._log.exception(e) 191 | cls._log.warning('Manga Tagger is unfamiliar with this error. Please log an issue for ' 192 | 'investigation.') 193 | 194 | cls._queue.task_done() 195 | time.sleep(1) 196 | 197 | class SeriesHandler(PatternMatchingEventHandler): 198 | _log = None 199 | 200 | @classmethod 201 | def class_name(cls): 202 | return cls.__name__ 203 | 204 | @classmethod 205 | def fully_qualified_class_name(cls): 206 | return f'{cls.__module__}.{cls.__name__}' 207 | 208 | def __init__(self, queue): 209 | self._log = logging.getLogger(self.fully_qualified_class_name()) 210 | super().__init__(patterns=['*.cbz']) 211 | self.queue = queue 212 | self._log.debug(f'{self.class_name()} class has been initialized') 213 | 214 | def on_created(self, event): 215 | self._log.debug(f'Event Type: {event.event_type}') 216 | self._log.debug(f'Event Path: {event.src_path}') 217 | 218 | self.queue.put(QueueEvent(event, QueueEventOrigin.WATCHDOG)) 219 | self._log.info(f'Creation event for "{event.src_path}" will be added to the queue') 220 | 221 | def on_moved(self, event): 222 | self._log.debug(f'Event Type: {event.event_type}') 223 | self._log.debug(f'Event Source Path: {event.src_path}') 224 | self._log.debug(f'Event Destination Path: {event.dest_path}') 225 | 226 | if Path(event.src_path) == Path(event.dest_path) and '-.-' in event.dest_path: 227 | self.queue.put(QueueEvent(event, QueueEventOrigin.WATCHDOG)) 228 | self._log.info(f'Moved event for "{event.dest_path}" will be added to the queue') 229 | -------------------------------------------------------------------------------- /MangaTaggerLib/utils.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import json 3 | import logging 4 | import subprocess 5 | import sys 6 | import os 7 | 8 | from logging.handlers import RotatingFileHandler, SocketHandler 9 | from pathlib import Path 10 | 11 | from pythonjsonlogger import jsonlogger 12 | 13 | from MangaTaggerLib.database import Database 14 | from MangaTaggerLib.task_queue import QueueWorker 15 | from MangaTaggerLib.api import AniList 16 | 17 | 18 | class AppSettings: 19 | mode_settings = None 20 | timezone = None 21 | version = None 22 | image = False 23 | image_first = False 24 | adult_result = False 25 | download_dir = None 26 | image_dir = None 27 | library_dir = None 28 | data_dir = None 29 | is_network_path = None 30 | 31 | processed_series = None 32 | 33 | _log = None 34 | 35 | @classmethod 36 | def load(cls): 37 | settings_location = Path(Path.cwd(), 'settings.json') 38 | if Path(settings_location).exists(): 39 | with open(settings_location, 'r') as settings_json: 40 | settings = json.load(settings_json) 41 | else: 42 | with open(settings_location, 'w+') as settings_json: 43 | settings = cls._create_settings() 44 | json.dump(settings, settings_json, indent=4) 45 | 46 | with open(settings_location, 'r+') as settings_json: 47 | if os.getenv("MANGA_TAGGER_DB_NAME") is not None: 48 | settings['database']['database_name'] = os.getenv("MANGA_TAGGER_DB_NAME") 49 | if os.getenv("MANGA_TAGGER_DB_HOST_ADDRESS") is not None: 50 | settings['database']['host_address'] = os.getenv("MANGA_TAGGER_DB_HOST_ADDRESS") 51 | if os.getenv("MANGA_TAGGER_DB_PORT") is not None: 52 | settings['database']['port'] = int(os.getenv("MANGA_TAGGER_DB_PORT")) 53 | if os.getenv("MANGA_TAGGER_DB_USERNAME") is not None: 54 | settings['database']['username'] = os.getenv("MANGA_TAGGER_DB_USERNAME") 55 | if os.getenv("MANGA_TAGGER_DB_PASSWORD") is not None: 56 | settings['database']['password'] = os.getenv("MANGA_TAGGER_DB_PASSWORD") 57 | if os.getenv("MANGA_TAGGER_DB_AUTH_SOURCE") is not None: 58 | settings['database']['auth_source'] = os.getenv("MANGA_TAGGER_DB_AUTH_SOURCE") 59 | if os.getenv("MANGA_TAGGER_DB_SELECTION_TIMEOUT") is not None: 60 | settings['database']['server_selection_timeout_ms'] = int(os.getenv("MANGA_TAGGER_DB_SELECTION_TIMEOUT")) 61 | 62 | if os.getenv("MANGA_TAGGER_DOWNLOAD_DIR") is not None: 63 | settings['application']['library']['download_dir'] = os.getenv("MANGA_TAGGER_DOWNLOAD_DIR") 64 | 65 | if os.getenv("MANGA_TAGGER_DATA_DIR") is not None: 66 | settings['application']['data_dir'] = os.getenv("MANGA_TAGGER_DATA_DIR") 67 | 68 | if os.getenv('TZ') is not None: 69 | settings['application']['timezone'] = os.getenv("TZ") 70 | 71 | if os.getenv("MANGA_TAGGER_DRY_RUN") is not None: 72 | if os.getenv("MANGA_TAGGER_DRY_RUN").lower() == 'true': 73 | settings['application']['dry_run']['enabled'] = True 74 | elif os.getenv("MANGA_TAGGER_DRY_RUN").lower() == 'false': 75 | settings['application']['dry_run']['enabled'] = False 76 | if os.getenv("MANGA_TAGGER_DB_INSERT") is not None: 77 | if os.getenv("MANGA_TAGGER_DB_INSERT").lower() == 'true': 78 | settings['application']['dry_run']['database_insert'] = True 79 | elif os.getenv("MANGA_TAGGER_DB_INSERT").lower() == 'false': 80 | settings['application']['dry_run']['database_insert'] = False 81 | if os.getenv("MANGA_TAGGER_RENAME_FILE") is not None: 82 | if os.getenv("MANGA_TAGGER_RENAME_FILE").lower() == 'true': 83 | settings['application']['dry_run']['rename_file'] = True 84 | elif os.getenv("MANGA_TAGGER_RENAME_FILE").lower() == 'false': 85 | settings['application']['dry_run']['rename_file'] = False 86 | if os.getenv("MANGA_TAGGER_WRITE_COMICINFO") is not None: 87 | if os.getenv("MANGA_TAGGER_WRITE_COMICINFO").lower() == 'true': 88 | settings['application']['dry_run']['write_comicinfo'] = True 89 | elif os.getenv("MANGA_TAGGER_WRITE_COMICINFO").lower() == 'false': 90 | settings['application']['dry_run']['write_comicinfo'] = False 91 | 92 | if os.getenv("MANGA_TAGGER_THREADS") is not None: 93 | settings['application']['multithreading']['threads'] = int(os.getenv("MANGA_TAGGER_THREADS")) 94 | if os.getenv("MANGA_TAGGER_MAX_QUEUE_SIZE") is not None: 95 | settings['application']['multithreading']['max_queue_size'] = int(os.getenv("MANGA_TAGGER_MAX_QUEUE_SIZE")) 96 | 97 | if os.getenv("MANGA_TAGGER_DEBUG_MODE") is not None: 98 | if os.getenv("MANGA_TAGGER_DEBUG_MODE").lower() == 'true': 99 | settings['application']['debug_mode'] = True 100 | elif os.getenv("MANGA_TAGGER_DEBUG_MODE").lower() == 'false': 101 | settings['application']['debug_mode'] = False 102 | 103 | if os.getenv("MANGA_TAGGER_IMAGE_COVER") is not None: 104 | if os.getenv("MANGA_TAGGER_IMAGE_COVER").lower() == 'true': 105 | settings['application']['image']['enabled'] = True 106 | elif os.getenv("MANGA_TAGGER_IMAGE_COVER").lower() == 'first': 107 | settings['application']['image']['enabled'] = True 108 | settings['application']['image']['first'] = True 109 | elif os.getenv("MANGA_TAGGER_IMAGE_COVER").lower() == 'false': 110 | settings['application']['image']['enabled'] = False 111 | if os.getenv("MANGA_TAGGER_IMAGE_DIR") is not None: 112 | settings['application']['image']['image_dir'] = os.getenv("MANGA_TAGGER_IMAGE_DIR") 113 | 114 | if os.getenv("MANGA_TAGGER_ADULT_RESULT") is not None: 115 | if os.getenv("MANGA_TAGGER_ADULT_RESULT").lower() == 'true': 116 | settings['application']['adult_result'] = True 117 | elif os.getenv("MANGA_TAGGER_ADULT_RESULT").lower() == 'false': 118 | settings['application']['adult_result'] = False 119 | 120 | if os.getenv("MANGA_TAGGER_LIBRARY_DIR") is not None: 121 | settings['application']['library']['dir'] = os.getenv("MANGA_TAGGER_LIBRARY_DIR") 122 | 123 | if os.getenv("MANGA_TAGGER_LOGGING_LEVEL") is not None: 124 | settings['logger']['logging_level'] = os.getenv("MANGA_TAGGER_LOGGING_LEVEL") 125 | if os.getenv("MANGA_TAGGER_LOGGING_DIR") is not None: 126 | settings['logger']['log_dir'] = os.getenv("MANGA_TAGGER_LOGGING_DIR") 127 | if os.getenv("MANGA_TAGGER_LOGGING_CONSOLE") is not None: 128 | if os.getenv("MANGA_TAGGER_LOGGING_CONSOLE").lower() == 'true': 129 | settings['logger']['console']['enabled'] = True 130 | elif os.getenv("MANGA_TAGGER_LOGGING_CONSOLE").lower() == 'false': 131 | settings['logger']['console']['enabled'] = False 132 | if os.getenv("MANGA_TAGGER_LOGGING_FILE") is not None: 133 | if os.getenv("MANGA_TAGGER_LOGGING_FILE").lower() == 'true': 134 | settings['logger']['file']['enabled'] = True 135 | elif os.getenv("MANGA_TAGGER_LOGGING_FILE").lower() == 'false': 136 | settings['logger']['file']['enabled'] = False 137 | if os.getenv("MANGA_TAGGER_LOGGING_JSON") is not None: 138 | if os.getenv("MANGA_TAGGER_LOGGING_JSON").lower() == 'true': 139 | settings['logger']['json']['enabled'] = True 140 | elif os.getenv("MANGA_TAGGER_LOGGING_JSON").lower() == 'false': 141 | settings['logger']['json']['enabled'] = False 142 | if os.getenv("MANGA_TAGGER_LOGGING_TCP") is not None: 143 | if os.getenv("MANGA_TAGGER_LOGGING_TCP").lower() == 'true': 144 | settings['logger']['tcp']['enabled'] = True 145 | elif os.getenv("MANGA_TAGGER_LOGGING_TCP").lower() == 'false': 146 | settings['logger']['tcp']['enabled'] = False 147 | if os.getenv("MANGA_TAGGER_LOGGING_JSONTCP") is not None: 148 | if os.getenv("MANGA_TAGGER_LOGGING_JSONTCP").lower() == 'true': 149 | settings['logger']['json_tcp']['enabled'] = True 150 | elif os.getenv("MANGA_TAGGER_LOGGING_JSONTCP").lower() == 'false': 151 | settings['logger']['json_tcp']['enabled'] = False 152 | 153 | with open(settings_location, 'w+') as settings_json: 154 | json.dump(settings, settings_json, indent=4) 155 | 156 | cls._initialize_logger(settings['logger']) 157 | cls._log = logging.getLogger(f'{cls.__module__}.{cls.__name__}') 158 | 159 | # Database Configuration 160 | cls._log.debug('Now setting database configuration...') 161 | 162 | Database.database_name = settings['database']['database_name'] 163 | Database.host_address = settings['database']['host_address'] 164 | Database.port = settings['database']['port'] 165 | Database.username = settings['database']['username'] 166 | Database.password = settings['database']['password'] 167 | Database.auth_source = settings['database']['auth_source'] 168 | Database.server_selection_timeout_ms = settings['database']['server_selection_timeout_ms'] 169 | 170 | cls._log.debug('Database settings configured!') 171 | Database.initialize() 172 | Database.print_debug_settings() 173 | 174 | # Download Directory Configuration 175 | # Set the download directory 176 | if settings['application']['library']['download_dir'] is not None: 177 | cls.download_dir = Path(settings['application']['library']['download_dir']) 178 | if not cls.download_dir.exists(): 179 | cls._log.info(f'Library directory "{AppSettings.library_dir}" does not exist; creating now.') 180 | cls.download_dir.mkdir() 181 | QueueWorker.download_dir = cls.download_dir 182 | cls._log.info(f'Download directory has been set as "{QueueWorker.download_dir}"') 183 | else: 184 | cls._log.critical('Manga Tagger cannot function without a download directory for moving processed ' 185 | 'files into. Configure one in the "settings.json" and try again.') 186 | sys.exit(1) 187 | 188 | # Set Application Timezone 189 | cls.timezone = settings['application']['timezone'] 190 | if os.getenv('TZ') is not None: 191 | cls.timezone = os.getenv("TZ") 192 | cls._log.debug(f'Timezone: {cls.timezone}') 193 | 194 | # Dry Run Mode Configuration 195 | # No logging here due to being handled at the INFO level in MangaTaggerLib 196 | if settings['application']['dry_run']['enabled']: 197 | cls.mode_settings = {'database_insert': settings['application']['dry_run']['database_insert'], 198 | 'rename_file': settings['application']['dry_run']['rename_file'], 199 | 'write_comicinfo': settings['application']['dry_run']['write_comicinfo']} 200 | 201 | # Multithreading Configuration 202 | if settings['application']['multithreading']['threads'] <= 0: 203 | QueueWorker.threads = 1 204 | else: 205 | QueueWorker.threads = settings['application']['multithreading']['threads'] 206 | 207 | cls._log.debug(f'Threads: {QueueWorker.threads}') 208 | 209 | if settings['application']['multithreading']['max_queue_size'] < 0: 210 | QueueWorker.max_queue_size = 0 211 | else: 212 | QueueWorker.max_queue_size = settings['application']['multithreading']['max_queue_size'] 213 | 214 | cls._log.debug(f'Max Queue Size: {QueueWorker.max_queue_size}') 215 | 216 | # Debug Mode - Prevent application from processing files 217 | if settings['application']['debug_mode']: 218 | QueueWorker._debug_mode = True 219 | 220 | cls._log.debug(f'Debug Mode: {QueueWorker._debug_mode}') 221 | 222 | # Image Directory 223 | if settings['application']['image']['enabled']: 224 | cls.image = True 225 | if settings['application']['image'].get('first'): 226 | cls.image_first = True 227 | if settings['application']['image']['image_dir'] is not None: 228 | if settings['application']['image']['image_dir'] is not None: 229 | cls.image_dir = settings['application']['image']['image_dir'] 230 | if not Path(cls.image_dir).exists(): 231 | cls._log.info(f'Image directory "{cls.image_dir}" does not exist; creating now.') 232 | Path(cls.image_dir).mkdir() 233 | cls._log.debug(f'Image Directory: {cls.image_dir}') 234 | else: 235 | cls._log.critical('Image cover is enabled but cannot function without an image directory for moving downloaded cover images ' 236 | 'files into. Configure one in the "settings.json" and try again.') 237 | sys.exit(1) 238 | else: 239 | cls._log.debug(f'Image cover not enabled') 240 | 241 | # Data Dir 242 | if settings['application']['data_dir'] is not None: 243 | cls.data_dir = settings['application']['data_dir'] 244 | cls._log.debug(f'Data Directory: {cls.library_dir}') 245 | if not Path(cls.data_dir).exists(): 246 | cls._log.info(f'Data directory "{AppSettings.library_dir}" does not exist; creating now.') 247 | Path(cls.data_dir).mkdir() 248 | 249 | # Enable or disable adult result 250 | if settings['application']['adult_result']: 251 | cls.adult_result = True 252 | cls._log.info('Adult result enabled.') 253 | 254 | # Manga Library Configuration 255 | if settings['application']['library']['dir'] is not None: 256 | cls.library_dir = settings['application']['library']['dir'] 257 | cls._log.debug(f'Library Directory: {cls.library_dir}') 258 | 259 | cls.is_network_path = settings['application']['library']['is_network_path'] 260 | 261 | if not Path(cls.library_dir).exists(): 262 | cls._log.info(f'Library directory "{AppSettings.library_dir}" does not exist; creating now.') 263 | Path(cls.library_dir).mkdir() 264 | else: 265 | cls._log.critical('Manga Tagger cannot function without a library directory for moving processed ' 266 | 'files into. Configure one in the "settings.json" and try again.') 267 | sys.exit(1) 268 | 269 | # Load necessary database tables 270 | Database.load_database_tables() 271 | 272 | # Initialize QueueWorker and load task queue 273 | QueueWorker.initialize() 274 | QueueWorker.load_task_queue() 275 | 276 | # Scan download directory for downloads not already in database upon loading 277 | cls._scan_download_dir() 278 | 279 | # Initialize API 280 | AniList.initialize() 281 | 282 | # Register function to be run prior to application termination 283 | atexit.register(cls._exit_handler) 284 | cls._log.debug(f'{cls.__name__} class has been initialized') 285 | 286 | @classmethod 287 | def _initialize_logger(cls, settings): 288 | logger = logging.getLogger('MangaTaggerLib') 289 | logging_level = settings['logging_level'] 290 | log_dir = settings['log_dir'] 291 | 292 | if logging_level.lower() == 'info': 293 | logging_level = logging.INFO 294 | elif logging_level.lower() == 'debug': 295 | logging_level = logging.DEBUG 296 | else: 297 | logger.critical('Logging level not of expected values "info" or "debug". Double check the configuration' 298 | 'in settings.json and try again.') 299 | sys.exit(1) 300 | 301 | logger.setLevel(logging_level) 302 | 303 | # Create log directory and allow the application access to it 304 | if not Path(log_dir).exists(): 305 | Path(log_dir).mkdir() 306 | 307 | # Console Logging 308 | if settings['console']['enabled']: 309 | log_handler = logging.StreamHandler() 310 | log_handler.setFormatter(logging.Formatter(settings['console']['log_format'])) 311 | logger.addHandler(log_handler) 312 | 313 | # File Logging 314 | if settings['file']['enabled']: 315 | log_handler = cls._create_rotating_file_handler(log_dir, 'log', settings, 'utf-8') 316 | log_handler.setFormatter(logging.Formatter(settings['file']['log_format'])) 317 | logger.addHandler(log_handler) 318 | 319 | # JSON Logging 320 | if settings['json']['enabled']: 321 | log_handler = cls._create_rotating_file_handler(log_dir, 'json', settings) 322 | log_handler.setFormatter(jsonlogger.JsonFormatter(settings['json']['log_format'])) 323 | logger.addHandler(log_handler) 324 | 325 | # Check TCP and JSON TCP for port conflicts before creating the handlers 326 | if settings['tcp']['enabled'] and settings['json_tcp']['enabled']: 327 | if settings['tcp']['port'] == settings['json_tcp']['port']: 328 | logger.critical('TCP and JSON TCP logging are both enabled, but their port numbers are the same. ' 329 | 'Either change the port value or disable one of the handlers in settings.json ' 330 | 'and try again.') 331 | sys.exit(1) 332 | 333 | # TCP Logging 334 | if settings['tcp']['enabled']: 335 | log_handler = SocketHandler(settings['tcp']['host'], settings['tcp']['port']) 336 | log_handler.setFormatter(logging.Formatter(settings['tcp']['log_format'])) 337 | logger.addHandler(log_handler) 338 | 339 | # JSON TCP Logging 340 | if settings['json_tcp']['enabled']: 341 | log_handler = SocketHandler(settings['json_tcp']['host'], settings['json_tcp']['port']) 342 | log_handler.setFormatter(jsonlogger.JsonFormatter(settings['json_tcp']['log_format'])) 343 | logger.addHandler(log_handler) 344 | 345 | @staticmethod 346 | def _create_rotating_file_handler(log_dir, extension, settings, encoder=None): 347 | return RotatingFileHandler(Path(log_dir, f'MangaTagger.{extension}'), 348 | maxBytes=settings['max_size'], 349 | backupCount=settings['backup_count'], 350 | encoding=encoder) 351 | 352 | @classmethod 353 | def _exit_handler(cls): 354 | cls._log.info('Initiating shutdown procedures...') 355 | 356 | # Stop worker threads 357 | QueueWorker.exit() 358 | 359 | # Save necessary database tables 360 | Database.save_database_tables() 361 | 362 | # Close MongoDB connection 363 | Database.close_connection() 364 | 365 | cls._log.info('Now exiting Manga Tagger') 366 | 367 | @classmethod 368 | def _create_settings(cls): 369 | 370 | return { 371 | "application": { 372 | "debug_mode": False, 373 | "timezone": "Europe/Paris", 374 | "data_dir": "data", 375 | "image": { 376 | "enabled" : True, 377 | "image_dir" : "cover" 378 | }, 379 | "adult_result" : False, 380 | "library": { 381 | "dir": "manga", 382 | "is_network_path": False, 383 | "download_dir": "downloads" 384 | }, 385 | "dry_run": { 386 | "enabled": False, 387 | "rename_file": False, 388 | "database_insert": False, 389 | "write_comicinfo": False 390 | }, 391 | "multithreading": { 392 | "threads": 8, 393 | "max_queue_size": 0 394 | } 395 | }, 396 | "database": { 397 | "database_name": "manga_tagger", 398 | "host_address": "localhost", 399 | "port": 27017, 400 | "username": "manga_tagger", 401 | "password": "Manga4LYFE", 402 | "auth_source": "admin", 403 | "server_selection_timeout_ms": 1 404 | }, 405 | "logger": { 406 | "logging_level": "info", 407 | "log_dir": "logs", 408 | "max_size": 10485760, 409 | "backup_count": 5, 410 | "console": { 411 | "enabled": True, 412 | "log_format": "%(asctime)s | %(threadName)s %(thread)d | %(name)s | %(levelname)s - %(message)s" 413 | }, 414 | "file": { 415 | "enabled": True, 416 | "log_format": "%(asctime)s | %(threadName)s %(thread)d | %(name)s | %(levelname)s - %(message)s" 417 | }, 418 | "json": { 419 | "enabled": False, 420 | "log_format": "%(threadName)s %(thread)d %(asctime)s %(name)s %(levelname)s %(message)s" 421 | }, 422 | "tcp": { 423 | "enabled": False, 424 | "host": "localhost", 425 | "port": 1798, 426 | "log_format": "%(threadName)s %(thread)d | %(asctime)s | %(name)s | %(levelname)s - %(message)s" 427 | }, 428 | "json_tcp": { 429 | "enabled": False, 430 | "host": "localhost", 431 | "port": 1799, 432 | "log_format": "%(threadName)s %(thread)d %(asctime)s %(name)s %(levelname)s %(message)s" 433 | } 434 | } 435 | } 436 | 437 | @classmethod 438 | def _scan_download_dir(cls): 439 | for directory in QueueWorker.download_dir.iterdir(): 440 | for manga_chapter in directory.glob('*.cbz'): 441 | if manga_chapter.name.strip('.cbz') not in QueueWorker.task_list.keys(): 442 | QueueWorker.add_to_task_queue(manga_chapter) 443 | 444 | def levenshtein_distance_no_numpy(s1, s2): 445 | """ 446 | Calculates the Levenshtein distance between two strings without using NumPy. 447 | 448 | Args: 449 | s1 (str): The first string. 450 | s2 (str): The second string. 451 | 452 | Returns: 453 | int: The Levenshtein distance between the two strings. 454 | """ 455 | 456 | rows = len(s1) + 1 457 | cols = len(s2) + 1 458 | distance = [[0 for _ in range(cols)] for _ in range(rows)] 459 | 460 | for i in range(1, rows): 461 | for j in range(1, cols): 462 | if s1[i - 1] == s2[j - 1]: 463 | distance[i][j] = distance[i - 1][j - 1] 464 | else: 465 | distance[i][j] = min(distance[i - 1][j] + 1, distance[i][j - 1] + 1, distance[i - 1][j - 1] + 1) 466 | 467 | return distance[rows - 1][cols - 1] 468 | def compare(s1, s2): 469 | s1 = s1.lower().strip('/[^a-zA-Z ]/g", ') 470 | s2 = s2.lower().strip('/[^a-zA-Z ]/g", ') 471 | 472 | rows = len(s1) + 1 473 | cols = len(s2) + 1 474 | distance = levenshtein_distance_no_numpy(s1, s2) 475 | 476 | for i in range(1, rows): 477 | distance[i][0] = i 478 | 479 | for i in range(1, cols): 480 | distance[0][i] = i 481 | 482 | for col in range(1, cols): 483 | for row in range(1, rows): 484 | if s1[row - 1] == s2[col - 1]: 485 | cost = 0 486 | else: 487 | cost = 2 488 | 489 | distance[row][col] = min(distance[row - 1][col] + 1, 490 | distance[row][col - 1] + 1, 491 | distance[row - 1][col - 1] + cost) 492 | 493 | return ((len(s1) + len(s2)) - distance[row][col]) / (len(s1) + len(s2)) 494 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Manga Tagger 2 | --- 3 | 4 | [![mt-hub-img]][mt-hub-lnk] 5 | [![Python tests](https://github.com/Banh-Canh/Manga-Tagger/actions/workflows/Run_Tests.yml/badge.svg)](https://github.com/Banh-Canh/Manga-Tagger/actions/workflows/Run_Tests.yml) 6 | ## Descriptions 7 | 8 | This fork doesn't require FMD2. Running MangaTagger.py will make it watch the directory configured in the settings.json. 9 | 10 | Intended to be used in a docker container: 11 | https://hub.docker.com/r/banhcanh/manga-tagger 12 | 13 | input Files still have to be named like this (they can be in their own `%MANGA%` directory, or not) : `%MANGA% -.- %CHAPTER%.cbz` 14 | 15 | ## Features: 16 | 17 | * Does not require FMD2 18 | * Only scrapes metadata from [Anilist](https://anilist.co/) 19 | * Support for Manga/Manhwa/Manhua 20 | * Download cover image for each chapter 21 | * Slightly increased filename parsing capability 22 | * Docker image available 23 | * Manga specific configuration 24 | 25 | More infos: 26 | https://github.com/Inpacchi/Manga-Tagger 27 | 28 | [Check the wiki for install and usage instructions](https://github.com/Banh-Canh/Manga-Tagger/wiki) 29 | --- 30 | 31 | ## Aditional info 32 | 33 | I recommend using this with my FMD2 docker image: https://hub.docker.com/r/banhcanh/docker-fmd2 34 | 35 | **Note**: 36 | - Environnement Variables overwrite the settings.json. In docker, it is only possible to configure with environnement variables. 37 | - Enabling adult result may give wrong manga match. Make sure the input manga title is as accurate as possible if enabling this or it may confuse Anilist's search. 38 | 39 | ## License 40 | [MIT](https://choosealicense.com/licenses/mit/) 41 | 42 | 43 | [mt-hub-img]: https://img.shields.io/docker/pulls/banhcanh/manga-tagger.svg 44 | [mt-hub-lnk]: https://hub.docker.com/r/banhcanh/manga-tagger 45 | -------------------------------------------------------------------------------- /images/manga_tagger_logo_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MangaManagerORG/Manga-Tagger/132379ad6e8c9057ed8c079d16838b2e74f5e420/images/manga_tagger_logo_cropped.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pymongo==3.11.0 2 | watchdog==2.1.9 3 | requests==2.28.1 4 | python_json_logger==2.0.1 5 | pytz==2020.1 6 | pillow 7 | BeautifulSoup4==4.9.3 8 | psutil 9 | -------------------------------------------------------------------------------- /root/etc/cont-init.d/30-config: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | # permissions 4 | chown -R abc:abc \ 5 | /config 6 | chown -R abc:abc \ 7 | /downloads 8 | chown -R abc:abc \ 9 | /app/Manga-Tagger 10 | -------------------------------------------------------------------------------- /root/etc/services.d/mangatagger/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | if [ -n "${UMASK_SET}" ] && [ -z "${UMASK}" ]; then 4 | echo -e "You are using a legacy method of defining umask\nplease update your environment variable from UMASK_SET to UMASK\nto keep the functionality after July 2021" 5 | umask ${UMASK_SET} 6 | fi 7 | 8 | cd /app/Manga-Tagger 9 | 10 | exec \ 11 | s6-setuidgid abc python3 /app/Manga-Tagger/MangaTagger.py 12 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "application": { 3 | "debug_mode": false, 4 | "timezone": "Europe/Paris", 5 | "data_dir": "data", 6 | "image": { 7 | "enabled": true, 8 | "image_dir": "cover" 9 | }, 10 | "adult_result": false, 11 | "library": { 12 | "dir": "manga", 13 | "is_network_path": false, 14 | "download_dir": "downloads" 15 | }, 16 | "dry_run": { 17 | "enabled": false, 18 | "rename_file": false, 19 | "database_insert": false, 20 | "write_comicinfo": false 21 | }, 22 | "multithreading": { 23 | "threads": 8, 24 | "max_queue_size": 0 25 | } 26 | }, 27 | "database": { 28 | "database_name": "manga_tagger", 29 | "host_address": "localhost", 30 | "port": 27017, 31 | "username": "manga_tagger", 32 | "password": "Manga4LYFE", 33 | "auth_source": "admin", 34 | "server_selection_timeout_ms": 1 35 | }, 36 | "logger": { 37 | "logging_level": "info", 38 | "log_dir": "logs", 39 | "max_size": 10485760, 40 | "backup_count": 5, 41 | "console": { 42 | "enabled": true, 43 | "log_format": "%(asctime)s | %(threadName)s %(thread)d | %(name)s | %(levelname)s - %(message)s" 44 | }, 45 | "file": { 46 | "enabled": true, 47 | "log_format": "%(asctime)s | %(threadName)s %(thread)d | %(name)s | %(levelname)s - %(message)s" 48 | }, 49 | "json": { 50 | "enabled": false, 51 | "log_format": "%(threadName)s %(thread)d %(asctime)s %(name)s %(levelname)s %(message)s" 52 | }, 53 | "tcp": { 54 | "enabled": false, 55 | "host": "localhost", 56 | "port": 1798, 57 | "log_format": "%(threadName)s %(thread)d | %(asctime)s | %(name)s | %(levelname)s - %(message)s" 58 | }, 59 | "json_tcp": { 60 | "enabled": false, 61 | "host": "localhost", 62 | "port": 1799, 63 | "log_format": "%(threadName)s %(thread)d %(asctime)s %(name)s %(levelname)s %(message)s" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MangaManagerORG/Manga-Tagger/132379ad6e8c9057ed8c079d16838b2e74f5e420/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/3D Kanojo Real Girl/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 80767, 3 | "status": "FINISHED", 4 | "synonyms": ["3D Girlfriend"], 5 | "volumes": 13, 6 | "siteUrl": "https://anilist.co/manga/80767", 7 | "title": { 8 | "romaji": "3D Kanojo: Real Girl", 9 | "english": "Real Girl", 10 | "native": "3D彼女 リアルガール" 11 | }, 12 | "type": "MANGA", 13 | "genres": ["Comedy", "Drama", "Romance", "Slice of Life"], 14 | "startDate": { 15 | "day": 23, 16 | "month": 7, 17 | "year": 2011 18 | }, 19 | "coverImage": { 20 | "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx80767-U74GsMO3XpQe.jpg" 21 | }, 22 | "staff": { 23 | "edges": [ 24 | { 25 | "node": { 26 | "name": { 27 | "first": "Mao", 28 | "last": "Nanami", 29 | "full": "Mao Nanami", 30 | "alternative": [ 31 | "" 32 | ] 33 | }, 34 | "siteUrl": "https://anilist.co/staff/107286" 35 | }, 36 | "role": "Story & Art" 37 | } 38 | ] 39 | }, 40 | "description": "Tsutsui Hikari is an otaku, and he mostly avoids social life. Hikari has only one friend at school, who is also a social misfit, and he is mocked brutally by most of his classmates for being creepy and weird. One day, he ends up having to clean the school pool with Igarashi Iroha, who appears to be pretty much everything he hates in real-life girls. She skips school, has a blunt manner, doesn't have female friends, and seems the sort to be promiscuous. However, she is friendly to Hikari, and even stands up to the people who make fun of him. Hikari's bitterness and trust issues lead him to say pretty harsh things to Iroha, but she never dismisses him as creepy. After a while, it starts to look like Iroha may become his first real-life, 3D girlfriend! Will he be able to handle it?

\n(Source: tethysdust)" 41 | } 42 | -------------------------------------------------------------------------------- /tests/data/BLEACH/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 30012, 3 | "status": "FINISHED", 4 | "synonyms": ["ブリーチ", "بلیچ", "سفید کننده"], 5 | "volumes": 74, 6 | "siteUrl": "https://anilist.co/manga/30012", 7 | "title": { 8 | "romaji": "BLEACH", 9 | "english": "Bleach", 10 | "native": "BLEACH" 11 | }, 12 | "type": "MANGA", 13 | "genres": [ 14 | "Action", 15 | "Adventure", 16 | "Supernatural" 17 | ], 18 | "startDate": { 19 | "day": 7, 20 | "month": 8, 21 | "year": 2001 22 | }, 23 | "coverImage": { 24 | "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30012-z7U138mUaPdN.png" 25 | }, 26 | "staff": { 27 | "edges": [ 28 | { 29 | "node": { 30 | "name": { 31 | "first": "Tite", 32 | "last": "Kubo", 33 | "full": "Tite Kubo", 34 | "alternative": [ 35 | "Taito Kubo", 36 | "Noriaki Kubo (\u4e45\u4fdd\u5ba3\u7ae0)" 37 | ] 38 | }, 39 | "siteUrl": "https://anilist.co/staff/96880" 40 | }, 41 | "role": "Story & Art" 42 | }, 43 | { 44 | "node": { 45 | "name": { 46 | "first": "Ricardo", 47 | "last": "Cruz", 48 | "full": "Ricardo Cruz", 49 | "alternative": [] 50 | }, 51 | "siteUrl": "https://anilist.co/staff/104809" 52 | }, 53 | "role": "Translator (Portuguese: vols 1-24)" 54 | }, 55 | { 56 | "node": { 57 | "name": { 58 | "first": "Drik", 59 | "last": "Sada", 60 | "full": "Drik Sada", 61 | "alternative": [ 62 | "Adriana Kazue Sada" 63 | ] 64 | }, 65 | "siteUrl": "https://anilist.co/staff/220675" 66 | }, 67 | "role": "Translator (Portuguese: vols 25-52, 71-74)" 68 | }, 69 | { 70 | "node": { 71 | "name": { 72 | "first": "Christine", 73 | "last": "Dashiell", 74 | "full": "Christine Dashiell", 75 | "alternative": [ 76 | "Christine Schilling" 77 | ] 78 | }, 79 | "siteUrl": "https://anilist.co/staff/219441" 80 | }, 81 | "role": "Translator (English: vol 49)" 82 | }, 83 | { 84 | "node": { 85 | "name": { 86 | "first": "Joe", 87 | "last": "Yamazaki", 88 | "full": "Joe Yamazaki", 89 | "alternative": [] 90 | }, 91 | "siteUrl": "https://anilist.co/staff/218469" 92 | }, 93 | "role": "Translator (English)" 94 | }, 95 | { 96 | "node": { 97 | "name": { 98 | "first": "Mark", 99 | "last": "McMurray", 100 | "full": "Mark McMurray", 101 | "alternative": [] 102 | }, 103 | "siteUrl": "https://anilist.co/staff/224874" 104 | }, 105 | "role": "Touch-up Art & Lettering (English: vols 16, 20)" 106 | }, 107 | { 108 | "node": { 109 | "name": { 110 | "first": "Mark", 111 | "last": "McMurray", 112 | "full": "Mark McMurray", 113 | "alternative": [] 114 | }, 115 | "siteUrl": "https://anilist.co/staff/224874" 116 | }, 117 | "role": "Lettering (English: vols: 16, 17, 19, 20, 22, 23, 24)" 118 | }, 119 | { 120 | "node": { 121 | "name": { 122 | "first": "Evan", 123 | "last": "Waldinger", 124 | "full": "Evan Waldinger", 125 | "alternative": [] 126 | }, 127 | "siteUrl": "https://anilist.co/staff/227608" 128 | }, 129 | "role": "Touch-up Art & Lettering (English: vol 21)" 130 | }, 131 | { 132 | "node": { 133 | "name": { 134 | "first": "Dave", 135 | "last": "Lanphear", 136 | "full": "Dave Lanphear", 137 | "alternative": [] 138 | }, 139 | "siteUrl": "https://anilist.co/staff/227606" 140 | }, 141 | "role": "Touch-up Art & Lettering (English: vols 3, 5, 6, 7, 8)" 142 | }, 143 | { 144 | "node": { 145 | "name": { 146 | "first": "Andy", 147 | "last": "Ristaino", 148 | "full": "Andy Ristaino", 149 | "alternative": [] 150 | }, 151 | "siteUrl": "https://anilist.co/staff/227607" 152 | }, 153 | "role": "Touch-up Art & Lettering (English)" 154 | }, 155 | { 156 | "node": { 157 | "name": { 158 | "first": "Marta", 159 | "last": "Gallego", 160 | "full": "Marta Gallego", 161 | "alternative": [] 162 | }, 163 | "siteUrl": "https://anilist.co/staff/225332" 164 | }, 165 | "role": "Translator (Spanish)" 166 | }, 167 | { 168 | "node": { 169 | "name": { 170 | "first": "Marc", 171 | "last": "Bernab\u00e9", 172 | "full": "Marc Bernab\u00e9", 173 | "alternative": [] 174 | }, 175 | "siteUrl": "https://anilist.co/staff/214718" 176 | }, 177 | "role": "Translator (Spanish)" 178 | }, 179 | { 180 | "node": { 181 | "name": { 182 | "first": "Ver\u00f3nica", 183 | "last": "Calafell", 184 | "full": "Ver\u00f3nica Calafell", 185 | "alternative": [ 186 | "Ver\u00f3nica Calafell Callejo" 187 | ] 188 | }, 189 | "siteUrl": "https://anilist.co/staff/225627" 190 | }, 191 | "role": "Translator (Spanish)" 192 | }, 193 | { 194 | "node": { 195 | "name": { 196 | "first": "Simona", 197 | "last": "Stanzani", 198 | "full": "Simona Stanzani", 199 | "alternative": [] 200 | }, 201 | "siteUrl": "https://anilist.co/staff/225407" 202 | }, 203 | "role": "Translator (Italian)" 204 | }, 205 | { 206 | "node": { 207 | "name": { 208 | "first": "Rafael", 209 | "last": "Morata", 210 | "full": "Rafael Morata", 211 | "alternative": [] 212 | }, 213 | "siteUrl": "https://anilist.co/staff/227609" 214 | }, 215 | "role": "Translator (Spanish)" 216 | }, 217 | { 218 | "node": { 219 | "name": { 220 | "first": "Daniel", 221 | "last": "B\u00fcchner", 222 | "full": "Daniel B\u00fcchner", 223 | "alternative": [] 224 | }, 225 | "siteUrl": "https://anilist.co/staff/227610" 226 | }, 227 | "role": "Translator (German)" 228 | }, 229 | { 230 | "node": { 231 | "name": { 232 | "first": "Kentarou", 233 | "last": "Kurimoto", 234 | "full": "Kentarou Kurimoto", 235 | "alternative": [] 236 | }, 237 | "siteUrl": "https://anilist.co/staff/119236" 238 | }, 239 | "role": "Assistant" 240 | }, 241 | { 242 | "node": { 243 | "name": { 244 | "first": "Shou", 245 | "last": "Aimoto", 246 | "full": "Shou Aimoto", 247 | "alternative": [] 248 | }, 249 | "siteUrl": "https://anilist.co/staff/98753" 250 | }, 251 | "role": "Assistant" 252 | }, 253 | { 254 | "node": { 255 | "name": { 256 | "first": "Hitoshi", 257 | "last": "Imoto", 258 | "full": "Hitoshi Imoto", 259 | "alternative": [] 260 | }, 261 | "siteUrl": "https://anilist.co/staff/126703" 262 | }, 263 | "role": "Assistant" 264 | }, 265 | { 266 | "node": { 267 | "name": { 268 | "first": "Anne-Sophie", 269 | "last": "Thevenon", 270 | "full": "Anne-Sophie Thevenon", 271 | "alternative": [ 272 | "Anne-Sophie Th\u00e9venon" 273 | ] 274 | }, 275 | "siteUrl": "https://anilist.co/staff/236835" 276 | }, 277 | "role": "Translator (French)" 278 | }, 279 | { 280 | "node": { 281 | "name": { 282 | "first": "Shou", 283 | "last": "Shiromoto", 284 | "full": "Shou Shiromoto", 285 | "alternative": [] 286 | }, 287 | "siteUrl": "https://anilist.co/staff/249345" 288 | }, 289 | "role": "Assistant" 290 | }, 291 | { 292 | "node": { 293 | "name": { 294 | "first": "Tayfun", 295 | "last": "Nitahara Haks\u00f6yliyen", 296 | "full": "Tayfun Nitahara Haks\u00f6yliyen", 297 | "alternative": [ 298 | "Tayfun Nitahara Haksoyliyen" 299 | ] 300 | }, 301 | "siteUrl": "https://anilist.co/staff/275233" 302 | }, 303 | "role": "Translator (Turkish: vols 1-17)" 304 | } 305 | ] 306 | }, 307 | "description": "Ichigo Kurosaki has always been able to see ghosts, but this ability doesn't change his life nearly as much as his close encounter with Rukia Kuchiki, a Soul Reaper and member of the mysterious Soul Society. While fighting a Hollow, an evil spirit that preys on humans who display psychic energy, Rukia attempts to lend Ichigo some of her powers so that he can save his family; but much to her surprise, Ichigo absorbs every last drop of her energy. Now a full-fledged Soul Reaper himself, Ichigo quickly learns that the world he inhabits is one full of dangerous spirits and, along with Rukia\u2014 who is slowly regaining her powers\u2014 it's Ichigo's job to protect the innocent from Hollows and help the spirits themselves find peace.

\n(Source: Anime News Network)

\nNote: Chapter count includes the 12-chapter \u201cTurn Back The Pendulum\u201d side story and 8 extra chapters." 308 | } 309 | -------------------------------------------------------------------------------- /tests/data/Hurejasik/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 86964, 3 | "status": "FINISHED", 4 | "synonyms": ["BASTARD", "不肖子", "Ублюдок", "Bâtard", "Bastardo", "バスタード"], 5 | "volumes": 5, 6 | "siteUrl": "https://anilist.co/manga/86964", 7 | "title": { 8 | "romaji": "Hurejasik", 9 | "english": "Bastard", 10 | "native": "후레자식" 11 | }, 12 | "type": "MANGA", 13 | "genres": [ 14 | "Drama", 15 | "Horror", 16 | "Mystery", 17 | "Psychological", 18 | "Romance", 19 | "Thriller" 20 | ], 21 | "startDate": { 22 | "day": 4, 23 | "month": 7, 24 | "year": 2014 25 | }, 26 | "coverImage": { 27 | "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86964-r7S3IbJNr4SD.jpg" 28 | }, 29 | "staff": { 30 | "edges": [ 31 | { 32 | "node": { 33 | "name": { 34 | "first": "Yeong-Chan", 35 | "last": "Hwang", 36 | "full": "Yeong-Chan Hwang", 37 | "alternative": [ 38 | "" 39 | ] 40 | }, 41 | "siteUrl": "https://anilist.co/staff/119717" 42 | }, 43 | "role": "Art" 44 | }, 45 | { 46 | "node": { 47 | "name": { 48 | "first": "Carnby", 49 | "last": "Kim", 50 | "full": "Carnby Kim", 51 | "alternative": [ 52 | "김칸비" 53 | ] 54 | }, 55 | "siteUrl": "https://anilist.co/staff/119718" 56 | }, 57 | "role": "Story" 58 | } 59 | ] 60 | }, 61 | "description": "There is nowhere that Seon Jin can find solace. At school, he is ruthlessly bullied due to his unsettlingly quiet nature and weak appearance. However, this is not the source of Jin's insurmountable terror: the thing that he fears more than anything else is his own father.

\nTo most, Jin's father is a successful businessman, good samaritan, and doting parent. But that is merely a facade; in truth, he is a deranged serial killer—and Jin is his unwilling accomplice. For years, they have been carrying out this ruse with the police being none the wiser. However, when his father takes an interest in the pretty transfer student Yoon Kyun, Jin must make a decision—be the coward who sends her to the gallows like all the rest, or be the bastard of a son who defies his wicked parent.

\n(Source: MAL Rewrite)

\nNote: Includes the prologue." 62 | } 63 | -------------------------------------------------------------------------------- /tests/data/Naruto/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 30011, 3 | "status": "FINISHED", 4 | "synonyms": ["נארוטו", "Наруто"], 5 | "volumes": 72, 6 | "siteUrl": "https://anilist.co/manga/30011", 7 | "title": { 8 | "romaji": "NARUTO", 9 | "english": "Naruto", 10 | "native": "NARUTO -ナルト-" 11 | }, 12 | "type": "MANGA", 13 | "genres": [ 14 | "Action", 15 | "Adventure" 16 | ], 17 | "startDate": { 18 | "day": 21, 19 | "month": 9, 20 | "year": 1999 21 | }, 22 | "coverImage": { 23 | "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx30011-9yUF1dXWgDOx.jpg" 24 | }, 25 | "staff": { 26 | "edges": [ 27 | { 28 | "node": { 29 | "name": { 30 | "first": "Masashi", 31 | "last": "Kishimoto", 32 | "full": "Masashi Kishimoto", 33 | "alternative": [ 34 | "" 35 | ] 36 | }, 37 | "siteUrl": "https://anilist.co/staff/96879" 38 | }, 39 | "role": "Story & Art" 40 | }, 41 | { 42 | "node": { 43 | "name": { 44 | "first": "Kazuhiro", 45 | "last": "Takahashi", 46 | "full": "Kazuhiro Takahashi", 47 | "alternative": [ 48 | "" 49 | ] 50 | }, 51 | "siteUrl": "https://anilist.co/staff/137325" 52 | }, 53 | "role": "Assistant (beta)" 54 | }, 55 | { 56 | "node": { 57 | "name": { 58 | "first": "Osamu", 59 | "last": "Kazisa", 60 | "full": "Osamu Kazisa", 61 | "alternative": [ 62 | "" 63 | ] 64 | }, 65 | "siteUrl": "https://anilist.co/staff/137326" 66 | }, 67 | "role": "Assistant (beta)" 68 | }, 69 | { 70 | "node": { 71 | "name": { 72 | "first": "Mikio", 73 | "last": "Ikemoto", 74 | "full": "Mikio Ikemoto", 75 | "alternative": [ 76 | "" 77 | ] 78 | }, 79 | "siteUrl": "https://anilist.co/staff/137327" 80 | }, 81 | "role": "Assistant (mob)" 82 | } 83 | ] 84 | }, 85 | "description": "Before Naruto's birth, a great demon fox had attacked the Hidden Leaf Village. A man known as the 4th Hokage sealed the demon inside the newly born Naruto, causing him to unknowingly grow up detested by his fellow villagers. Despite his lack of talent in many areas of ninjutsu, Naruto strives for only one goal: to gain the title of Hokage, the strongest ninja in his village. Desiring the respect he never received, Naruto works towards his dream with fellow friends Sasuke and Sakura and mentor Kakashi as they go through many trials and battles that come with being a ninja. " 86 | } 87 | -------------------------------------------------------------------------------- /tests/database.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | class ProcFilesTable: 5 | @classmethod 6 | def search_return_results(cls, manga_title, chapter_number): 7 | return { 8 | "chapter_number": "001", 9 | "new_filename": "Absolute Boyfriend 001.cbz", 10 | "old_filename": "Absolute Boyfriend -.- Absolute Boyfriend 01 Lover Shop.cbz", 11 | "process_date": datetime.now().strftime('%Y-%m-%d'), 12 | "series_title": "Absolute Boyfriend" 13 | } 14 | 15 | @classmethod 16 | def search_return_results_version(cls, manga_title, chapter_number): 17 | return { 18 | "chapter_number": "001", 19 | "new_filename": "Absolute Boyfriend 001.cbz", 20 | "old_filename": "Absolute Boyfriend -.- Absolute Boyfriend 01 Lover Shop v2.cbz", 21 | "process_date": datetime.now().strftime('%Y-%m-%d'), 22 | "series_title": "Absolute Boyfriend" 23 | } 24 | 25 | @classmethod 26 | def search_return_no_results(cls, manga_title, chapter_number): 27 | return None 28 | 29 | 30 | class MetadataTable: 31 | @classmethod 32 | def search_return_no_results(cls, manga_title): 33 | return None 34 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import unittest 5 | from pathlib import Path 6 | from unittest.mock import patch 7 | 8 | from MangaTaggerLib.api import AniList 9 | from MangaTaggerLib.MangaTaggerLib import metadata_tagger, construct_comicinfo_xml 10 | from MangaTaggerLib.models import Metadata 11 | from tests.database import MetadataTable as MetadataTableTest 12 | 13 | 14 | # noinspection DuplicatedCode 15 | class TestMetadata(unittest.TestCase): 16 | @classmethod 17 | def setUpClass(cls) -> None: 18 | cls.data_dir = 'tests/data' 19 | cls.data_file = 'data.json' 20 | 21 | def setUp(self) -> None: 22 | logging.disable(logging.CRITICAL) 23 | AniList.initialize() 24 | self.maxDiff = None 25 | patch1 = patch('MangaTaggerLib.models.AppSettings') 26 | self.models_AppSettings = patch1.start() 27 | self.addCleanup(patch1.stop) 28 | self.models_AppSettings.timezone = 'America/New_York' 29 | 30 | patch2 = patch('MangaTaggerLib.MangaTaggerLib.MetadataTable') 31 | self.MetadataTable = patch2.start() 32 | self.addCleanup(patch2.stop) 33 | self.MetadataTable.search_by_search_value = MetadataTableTest.search_return_no_results 34 | self.MetadataTable.search_by_series_title = MetadataTableTest.search_return_no_results 35 | self.MetadataTable.search_by_series_title_eng = MetadataTableTest.search_return_no_results 36 | 37 | patch3 = patch('MangaTaggerLib.MangaTaggerLib.AppSettings') 38 | self.MangaTaggerLib_AppSettings = patch3.start() 39 | self.addCleanup(patch3.stop) 40 | 41 | def test_comicinfo_xml_creation_case_1(self): 42 | title = 'BLEACH' 43 | 44 | self.MangaTaggerLib_AppSettings.mode_settings = {} 45 | 46 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 47 | anilist_details = json.load(data) 48 | 49 | manga_metadata = Metadata(title, {}, anilist_details) 50 | 51 | self.assertTrue(construct_comicinfo_xml(manga_metadata, '001', {}, None)) 52 | 53 | def test_comicinfo_xml_creation_case_2(self): 54 | title = 'Naruto' 55 | 56 | self.MangaTaggerLib_AppSettings.mode_settings = {} 57 | 58 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 59 | anilist_details = json.load(data) 60 | 61 | manga_metadata = Metadata(title, {}, anilist_details) 62 | 63 | self.assertTrue(construct_comicinfo_xml(manga_metadata, '001', {}, None)) 64 | 65 | def test_metadata_case_1(self): 66 | title = 'BLEACH' 67 | 68 | self.MangaTaggerLib_AppSettings.mode_settings = {'write_comicinfo': False} 69 | self.MangaTaggerLib_AppSettings.mode_settings = {'rename_file': False} 70 | 71 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 72 | anilist_details = json.load(data) 73 | 74 | expected_manga_metadata = Metadata(title, {}, anilist_details) 75 | actual_manga_metadata = metadata_tagger("NOWHERE", title, '001', "MANGA", {}, None) 76 | 77 | self.assertEqual(expected_manga_metadata.test_value(), actual_manga_metadata.test_value()) 78 | 79 | def test_metadata_case_2(self): 80 | title = 'Naruto' 81 | 82 | self.MangaTaggerLib_AppSettings.mode_settings = {'write_comicinfo': False} 83 | self.MangaTaggerLib_AppSettings.mode_settings = {'rename_file': False} 84 | 85 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 86 | anilist_details = json.load(data) 87 | 88 | expected_manga_metadata = Metadata(title, {}, anilist_details) 89 | actual_manga_metadata = metadata_tagger("NOWHERE", title, '001', "MANGA", {}, None) 90 | 91 | self.assertEqual(expected_manga_metadata.test_value(), actual_manga_metadata.test_value()) 92 | 93 | def test_metadata_case_3(self): 94 | title = '3D Kanojo Real Girl' 95 | downloaded_title = '3D Kanojo' 96 | 97 | self.MangaTaggerLib_AppSettings.mode_settings = {'write_comicinfo': False} 98 | self.MangaTaggerLib_AppSettings.mode_settings = {'rename_file': False} 99 | self.MangaTaggerLib_AppSettings.adult_result = False 100 | 101 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 102 | anilist_details = json.load(data) 103 | 104 | expected_manga_metadata = Metadata(title, {}, anilist_details) 105 | actual_manga_metadata = metadata_tagger("NOWHERE", downloaded_title, '001', "MANGA", {}, None) 106 | 107 | self.assertEqual(expected_manga_metadata.test_value(), actual_manga_metadata.test_value()) 108 | 109 | def test_metadata_case_4(self): 110 | title = 'Hurejasik' 111 | downloaded_title = 'Bastard' 112 | 113 | self.MangaTaggerLib_AppSettings.mode_settings = {'write_comicinfo': False} 114 | self.MangaTaggerLib_AppSettings.mode_settings = {'rename_file': False} 115 | 116 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 117 | anilist_details = json.load(data) 118 | 119 | expected_manga_metadata = Metadata(title, {}, anilist_details) 120 | actual_manga_metadata = metadata_tagger("NOWHERE", downloaded_title, '001', "MANGA", {}, None) 121 | 122 | self.assertEqual(expected_manga_metadata.test_value(), actual_manga_metadata.test_value()) 123 | 124 | def test_metadata_case_5(self): 125 | title = 'Naruto' 126 | 127 | self.MangaTaggerLib_AppSettings.mode_settings = {'write_comicinfo': False} 128 | self.MangaTaggerLib_AppSettings.mode_settings = {'rename_file': False} 129 | 130 | with open(Path(self.data_dir, title, self.data_file), encoding='utf-8') as data: 131 | anilist_details = json.load(data) 132 | 133 | expected_manga_metadata = Metadata(title, {}, anilist_details) 134 | actual_manga_metadata = metadata_tagger("NOWHERE", title, '001', "ONE_SHOT", {}, None) 135 | 136 | self.assertNotEqual(expected_manga_metadata.test_value(), actual_manga_metadata.test_value()) 137 | -------------------------------------------------------------------------------- /tests/test_manga.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | import shutil 4 | from pathlib import Path 5 | from typing import List 6 | from unittest.mock import patch 7 | 8 | from MangaTaggerLib.MangaTaggerLib import filename_parser, rename_action 9 | from MangaTaggerLib.errors import FileAlreadyProcessedError, FileUpdateNotRequiredError 10 | from tests.database import ProcFilesTable as ProcFilesTableTest 11 | 12 | class FilenameParserTestCase(unittest.TestCase): 13 | 14 | def test_filename_parser_manga(self): 15 | filename = "Naruto -.- Chap 0.cbz" 16 | directory_name = "Naruto" 17 | logging_info = { 'event_id': 0, 'manga_title': directory_name, "original_filename": filename } 18 | expected_result = ("Naruto", "000", "MANGA",None) 19 | result = filename_parser(filename, logging_info) 20 | self.assertEqual(expected_result, result) 21 | 22 | def test_filename_parser_decnumber(self): 23 | filename = "Naruto -.- Chap 15.5.cbz" 24 | directory_name = "Naruto" 25 | logging_info = { 'event_id': 0, 'manga_title': directory_name, "original_filename": filename } 26 | expected_result = ("Naruto", "015.5", "MANGA",None) 27 | result = filename_parser(filename, logging_info) 28 | self.assertEqual(expected_result, result) 29 | 30 | def test_filename_parser_oneshot(self): 31 | ## Naruto Oneshot for the same author 32 | filename = "Naruto -.- Oneshot.cbz" 33 | directory_name = "Naruto" 34 | logging_info = { 'event_id': 0, 'manga_title': directory_name, "original_filename": filename } 35 | expected_result = ("Naruto", "000", "ONE_SHOT",None) 36 | result = filename_parser(filename, logging_info) 37 | self.assertEqual(expected_result, result) 38 | 39 | def test_filename_parser_prologue(self): 40 | ## Berserk Prologue 1-16 goes back to chapter 1 once the prologue ends. 41 | filename = "Berserk -.- Prologue 5.cbz" 42 | directory_name = "Berserk" 43 | logging_info = { 'event_id': 0, 'manga_title': directory_name, "original_filename": filename } 44 | expected_result = ("Berserk", "000.5", "MANGA",None) 45 | result = filename_parser(filename, logging_info) 46 | self.assertEqual(expected_result, result) 47 | 48 | def test_filename_parser_ignore_fluff(self): 49 | ## Ignore Volume, chapter name and (part) 50 | filename = "One Piece -.- Volume 50 Episode 156 A Chapter Name (15).cbz" 51 | directory_name = "Naruto" 52 | logging_info = { 'event_id': 0, 'manga_title': directory_name, "original_filename": filename } 53 | expected_result = ("One Piece", "156", "MANGA","50") 54 | result = filename_parser(filename, logging_info) 55 | self.assertEqual(expected_result, result) 56 | 57 | def test_filename_parser_ignore_fluff_2(self): 58 | ## Ignore Volume, chapter name and (part) 59 | filename = "Kuma Kuma Kuma Bear -.- Ch. 064 - Kuma-san and the Shop's Opening Day 2.cbz" 60 | directory_name = "Kuma Kuma Kuma Bear" 61 | logging_info = {'event_id': 0, 'manga_title': directory_name, "original_filename": filename} 62 | expected_result = ("Kuma Kuma Kuma Bear", "064", "MANGA", None) 63 | result = filename_parser(filename, logging_info) 64 | self.assertEqual(expected_result, result) 65 | class TestMangaRenameAction(unittest.TestCase): 66 | download_dir = Path('tests/downloads') 67 | library_dir = Path('tests/library') 68 | current_file = None 69 | new_file = None 70 | 71 | @classmethod 72 | def setUpClass(cls) -> None: 73 | logging.disable(logging.CRITICAL) 74 | cls.current_file = Path(cls.download_dir, 'Absolute Boyfriend -.- Absolute Boyfriend 01 Lover Shop.cbz') 75 | cls.new_file = Path(cls.library_dir, 'Absolute Boyfriend 001.cbz') 76 | 77 | def setUp(self) -> None: 78 | self.download_dir.mkdir() 79 | self.library_dir.mkdir() 80 | 81 | def tearDown(self) -> None: 82 | shutil.rmtree(self.download_dir) 83 | shutil.rmtree(self.library_dir) 84 | 85 | @patch('MangaTaggerLib.MangaTaggerLib.ProcFilesTable') 86 | @patch('MangaTaggerLib.MangaTaggerLib.CURRENTLY_PENDING_RENAME', new_callable=list) 87 | def test_rename_action_initial(self, CURRENTLY_PENDING_RENAME: List, ProcFilesTable): 88 | """ 89 | Tests for initial file rename when no results are returned from the database. Test should execute without error. 90 | """ 91 | self.current_file.touch() 92 | ProcFilesTable.search = ProcFilesTableTest.search_return_no_results 93 | 94 | CURRENTLY_PENDING_RENAME.append(self.new_file) 95 | 96 | self.assertFalse(rename_action(self.current_file, self.new_file, 'Absolute Boyfriend', '01', {})) 97 | 98 | @patch('MangaTaggerLib.MangaTaggerLib.ProcFilesTable') 99 | def test_rename_action_duplicate(self, ProcFilesTable): 100 | """ 101 | Tests for duplicate file rename when results are returned from the database. Test should assert 102 | FileAlreadyProcessedError. 103 | """ 104 | self.current_file.touch() 105 | ProcFilesTable.search = ProcFilesTableTest.search_return_results 106 | 107 | with self.assertRaises(FileAlreadyProcessedError): 108 | rename_action(self.current_file, self.new_file, 'Absolute Boyfriend', '01', {}) 109 | 110 | @patch('MangaTaggerLib.MangaTaggerLib.ProcFilesTable') 111 | def test_rename_action_downgrade(self, ProcFilesTable): 112 | """ 113 | Tests for version in file rename when results are returned from the database. Since the current file is a 114 | lower version than the existing file, test should assert FileUpdateNotRequiredError. 115 | """ 116 | self.current_file.touch() 117 | ProcFilesTable.search = ProcFilesTableTest.search_return_results_version 118 | 119 | with self.assertRaises(FileUpdateNotRequiredError): 120 | rename_action(self.current_file, self.new_file, 'Absolute Boyfriend', '01', {}) 121 | 122 | @patch('MangaTaggerLib.MangaTaggerLib.ProcFilesTable') 123 | def test_rename_action_version_duplicate(self, ProcFilesTable): 124 | """ 125 | Tests for version and duplicate file rename when results are returned from the database. Since the current 126 | version is the same as the one in the database, test should assert FileUpdateNotRequiredError. 127 | """ 128 | self.current_file = Path(self.download_dir, 'Absolute Boyfriend -.- Absolute Boyfriend 01 Lover Shop v2.cbz') 129 | self.current_file.touch() 130 | 131 | ProcFilesTable.search = ProcFilesTableTest.search_return_results_version 132 | 133 | with self.assertRaises(FileUpdateNotRequiredError): 134 | rename_action(self.current_file, self.new_file, 'Absolute Boyfriend', '01', {}) 135 | 136 | @patch('MangaTaggerLib.MangaTaggerLib.ProcFilesTable') 137 | @patch('MangaTaggerLib.MangaTaggerLib.CURRENTLY_PENDING_RENAME', new_callable=list) 138 | def test_rename_action_upgrade(self, CURRENTLY_PENDING_RENAME: List, ProcFilesTable): 139 | """ 140 | Tests for version in file rename when results are returned from the database. Since the current file is a 141 | higher version than the exisitng file, test should execute without error. 142 | """ 143 | self.current_file = Path(self.download_dir, 'Absolute Boyfriend -.- Absolute Boyfriend 01 Lover Shop v3.cbz') 144 | self.current_file.touch() 145 | 146 | self.new_file.touch() 147 | CURRENTLY_PENDING_RENAME.append(self.new_file) 148 | 149 | ProcFilesTable.search = ProcFilesTableTest.search_return_results_version 150 | 151 | self.assertFalse(rename_action(self.current_file, self.new_file, 'Absolute Boyfriend', '01', {})) 152 | 153 | 154 | 155 | --------------------------------------------------------------------------------