├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── contribute_request.md │ └── feature_request.md └── workflows │ ├── deploy.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── python.code-snippets ├── settings.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Kapowarr.py ├── LICENSE ├── README.md ├── backend ├── base │ ├── custom_exceptions.py │ ├── definitions.py │ ├── file_extraction.py │ ├── files.py │ ├── helpers.py │ └── logging.py ├── features │ ├── download_queue.py │ ├── library_import.py │ ├── mass_edit.py │ ├── post_processing.py │ ├── search.py │ └── tasks.py ├── implementations │ ├── blocklist.py │ ├── comicvine.py │ ├── conversion.py │ ├── converters.py │ ├── credentials.py │ ├── direct_clients │ │ └── mega.py │ ├── download_clients.py │ ├── external_clients.py │ ├── flaresolverr.py │ ├── getcomics.py │ ├── matching.py │ ├── naming.py │ ├── root_folders.py │ ├── torrent_clients │ │ └── qBittorrent.py │ └── volumes.py ├── internals │ ├── db.py │ ├── db_migration.py │ ├── db_models.py │ ├── server.py │ └── settings.py └── lib │ ├── rar_bsd_64 │ ├── rar_linux_64 │ └── rar_windows_64.exe ├── docker-compose.yml ├── docs ├── assets │ ├── css │ │ └── extra.css │ └── img │ │ ├── Docker_Desktop_setup.png │ │ └── favicon.svg ├── beta │ └── beta.md ├── general_info │ ├── downloading.md │ ├── features.md │ ├── managing_volume.md │ ├── matching.md │ └── workings.md ├── index.md ├── installation │ ├── docker.md │ ├── installation.md │ ├── manual_install.md │ └── setup_after_installation.md ├── other_docs │ ├── api.md │ ├── faq.md │ ├── rate_limiting.md │ └── reporting.md └── settings │ ├── download.md │ ├── downloadclients.md │ ├── general.md │ ├── mediamanagement.md │ └── settings.md ├── frontend ├── api.py ├── static │ ├── css │ │ ├── add_volume.css │ │ ├── blocklist.css │ │ ├── general.css │ │ ├── history.css │ │ ├── library_import.css │ │ ├── login.css │ │ ├── page_not_found.css │ │ ├── queue.css │ │ ├── settings.css │ │ ├── status.css │ │ ├── tasks.css │ │ ├── view_volume.css │ │ ├── volumes.css │ │ └── window.css │ ├── img │ │ ├── arrow_down.svg │ │ ├── arrow_up.svg │ │ ├── blocklist.svg │ │ ├── cancel.svg │ │ ├── check.svg │ │ ├── check_circle.svg │ │ ├── convert.svg │ │ ├── delete.svg │ │ ├── discord.ico │ │ ├── download.svg │ │ ├── edit.svg │ │ ├── favicon.svg │ │ ├── files.svg │ │ ├── getcomics.png │ │ ├── github.svg │ │ ├── ko-fi.webp │ │ ├── loading.svg │ │ ├── manual_search.svg │ │ ├── menu.svg │ │ ├── refresh.svg │ │ ├── rename.svg │ │ ├── save.svg │ │ ├── search.svg │ │ └── settings.svg │ └── js │ │ ├── add_volume.js │ │ ├── auth.js │ │ ├── blocklist.js │ │ ├── general.js │ │ ├── history.js │ │ ├── library_import.js │ │ ├── login.js │ │ ├── queue.js │ │ ├── settings_download.js │ │ ├── settings_download_clients.js │ │ ├── settings_general.js │ │ ├── settings_mediamanagement.js │ │ ├── status.js │ │ ├── tasks.js │ │ ├── view_volume.js │ │ ├── volumes.js │ │ └── window.js ├── templates │ ├── add_volume.html │ ├── base.html │ ├── blocklist.html │ ├── history.html │ ├── library_import.html │ ├── login.html │ ├── page_not_found.html │ ├── queue.html │ ├── settings_download.html │ ├── settings_download_clients.html │ ├── settings_general.html │ ├── settings_mediamanagement.html │ ├── status.html │ ├── tasks.html │ ├── view_volume.html │ └── volumes.html └── ui.py ├── project_management ├── docs-requirements.txt └── mkdocs.yml ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt └── tests └── Tbackend ├── __init__.py └── file_extraction.py /.dockerignore: -------------------------------------------------------------------------------- 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 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | pip-wheel-metadata/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 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 | # Database 132 | db/ 133 | 134 | # VS code 135 | *.code-workspace 136 | .vscode/ 137 | 138 | # Docker 139 | Dockerfile 140 | .dockerignore 141 | docker-compose.yml 142 | 143 | # Git(hub) 144 | .gitignore 145 | .git/ 146 | .github/ 147 | 148 | # Various files 149 | *.md 150 | LICENSE 151 | 152 | # Tests 153 | tests/ 154 | 155 | # Downloads 156 | temp_downloads/* 157 | 158 | # Project management files 159 | docs/ 160 | project_management/ 161 | requirements-dev.txt 162 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: casvt 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | **Description of the bug** 13 | 14 | 15 | 16 | **To Reproduce** 17 | 18 | 19 | 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behaviour** 26 | 27 | 28 | 29 | **Screenshots** 30 | 31 | 32 | 33 | **Version info** 34 | 35 | 36 | 37 | 38 | 39 | **Additional context** 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/contribute_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Contribute request 3 | about: Announce that you want to contribute to the project 4 | title: '' 5 | labels: contributing 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What do you want to contribute? (e.g. add a feature, fix a bug, improve documentation)** 11 | 12 | **How long do you think it will take to finish it? (e.g. one week, one month, three months)** -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Additional context** 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - development 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy docs 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | name: Setup checkout 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-python@v4 19 | name: Setup python 3.8 20 | with: 21 | python-version: 3.8 22 | cache: 'pip' 23 | 24 | - run: pip install -r project_management/docs-requirements.txt 25 | name: Install dependencies 26 | 27 | - run: mkdocs gh-deploy --force -f project_management/mkdocs.yml 28 | name: Build docs 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - development 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.8", "3.9", "3.10", "3.11"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | name: Set up checkout 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: actions/setup-python@v4 22 | name: Set up Python ${{ matrix.python-version }} 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | cache: 'pip' 26 | 27 | - run: pip install -r requirements.txt 28 | name: Install dependencies 29 | 30 | - run: | 31 | mkdir db 32 | python -m unittest discover -s ./tests -p '*.py' 33 | name: Run Tests -------------------------------------------------------------------------------- /.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 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | pip-wheel-metadata/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 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 | # database 132 | *.db 133 | *.db-shm 134 | *.db-wal 135 | 136 | # vs-code 137 | *.code-workspace 138 | 139 | # Downloads 140 | temp_downloads/* 141 | 142 | # Project management files 143 | release*.sh 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: 5.13.2 4 | hooks: 5 | - id: isort 6 | name: isort 7 | additional_dependencies: [ 8 | typing_extensions ~= 4.12, 9 | requests ~= 2.31, 10 | beautifulsoup4 ~= 4.12, 11 | flask ~= 3.0, 12 | waitress ~= 3.0, 13 | "cryptography ~= 44.0, >= 44.0.1", 14 | bencoding ~= 0.2, 15 | aiohttp ~= 3.9, 16 | flask-socketio ~= 5.3, 17 | websocket-client ~= 1.3 18 | ] 19 | 20 | - repo: local 21 | hooks: 22 | - id: mypy 23 | name: mypy 24 | language: python 25 | pass_filenames: false 26 | additional_dependencies: [ 27 | mypy~=1.10, 28 | 29 | typing_extensions ~= 4.12, 30 | requests ~= 2.31, 31 | beautifulsoup4 ~= 4.12, 32 | flask ~= 3.0, 33 | waitress ~= 3.0, 34 | "cryptography ~= 44.0, >= 44.0.1", 35 | bencoding ~= 0.2, 36 | aiohttp ~= 3.9, 37 | flask-socketio ~= 5.3, 38 | websocket-client ~= 1.3 39 | ] 40 | entry: python -m mypy --explicit-package-bases . 41 | 42 | - id: unittest 43 | name: unittest 44 | language: python 45 | pass_filenames: false 46 | additional_dependencies: [ 47 | typing_extensions ~= 4.12, 48 | requests ~= 2.31, 49 | beautifulsoup4 ~= 4.12, 50 | flask ~= 3.0, 51 | waitress ~= 3.0, 52 | "cryptography ~= 44.0, >= 44.0.1", 53 | bencoding ~= 0.2, 54 | aiohttp ~= 3.9, 55 | flask-socketio ~= 5.3, 56 | websocket-client ~= 1.3 57 | ] 58 | entry: python -m unittest discover -s ./tests -p '*.py' 59 | 60 | - repo: https://github.com/hhatto/autopep8 61 | rev: v2.2.0 62 | hooks: 63 | - id: autopep8 64 | name: autopep8 65 | additional_dependencies: [ 66 | typing_extensions ~= 4.12, 67 | requests ~= 2.31, 68 | beautifulsoup4 ~= 4.12, 69 | flask ~= 3.0, 70 | waitress ~= 3.0, 71 | "cryptography ~= 44.0, >= 44.0.1", 72 | bencoding ~= 0.2, 73 | aiohttp ~= 3.9, 74 | flask-socketio ~= 5.3, 75 | websocket-client ~= 1.3 76 | ] 77 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Current File", 5 | "type": "debugpy", 6 | "request": "launch", 7 | "program": "${file}", 8 | "justMyCode": true 9 | }, 10 | { 11 | "name": "Main File", 12 | "type": "debugpy", 13 | "request": "launch", 14 | "python": "/bin/python3", 15 | "program": "${workspaceFolder}/Kapowarr.py", 16 | "justMyCode": true 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/python.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your Kapowarr workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | "Profile Selected Code": { 19 | "scope": "python", 20 | "description": "Profile the selected code using cProfile", 21 | "prefix": "profile", 22 | "body": [ 23 | "${TM_SELECTED_TEXT/^([ \\t]*)[\\s\\S]*$/$1/}from cProfile import Profile", 24 | "${TM_SELECTED_TEXT/^([ \\t]*)[\\s\\S]*$/$1/}from pstats import Stats", 25 | "", 26 | "${TM_SELECTED_TEXT/^([ \\t]*)[\\s\\S]*$/$1/}with Profile() as pr:", 27 | "${TM_SELECTED_TEXT/^(.+?)$(\\r?\\n)?/ ${1:pass}$2/gm}$0", 28 | "", 29 | "${TM_SELECTED_TEXT/^([ \\t]*)[\\s\\S]*$/$1/}Stats(pr).dump_stats('stats.prof')", 30 | "" 31 | ], 32 | } 33 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.tabSize": 4, 4 | 5 | "[python]": { 6 | "editor.codeActionsOnSave": { 7 | "source.organizeImports": "always", 8 | }, 9 | "editor.formatOnSave": true, 10 | "editor.defaultFormatter": "ms-python.autopep8" 11 | }, 12 | "isort.check": true, 13 | "isort.severity": { 14 | "W": "Warning", 15 | "E": "Warning" 16 | }, 17 | "isort.args": [ 18 | "--jobs", "-1" 19 | ], 20 | 21 | "python.testing.unittestEnabled": true, 22 | "python.testing.unittestArgs": [ 23 | "-s", "./tests", 24 | "-p", "*.py" 25 | ], 26 | 27 | "python.analysis.typeCheckingMode": "standard", 28 | "python.analysis.diagnosticMode": "workspace", 29 | 30 | "mypy-type-checker.reportingScope": "workspace", 31 | "mypy-type-checker.preferDaemon": false, 32 | "mypy-type-checker.args": [ 33 | "--explicit-package-bases" 34 | ], 35 | 36 | "cSpell.words": [ 37 | "behaviour", 38 | "blocklist", 39 | "customisable", 40 | "customised", 41 | "mediamanagement", 42 | "TPB's", 43 | "traceback", 44 | "mediafire", 45 | "wetransfer" 46 | ], 47 | "cSpell.languageSettings": [ 48 | { 49 | "languageId": "log", 50 | "enabled": false 51 | } 52 | ], 53 | } 54 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Format All", 6 | "type": "shell", 7 | "command": "python3 -m isort .; python3 -m autopep8 --in-place -r .", 8 | "windows": { 9 | "command": "python -m isort .; python -m autopep8 --in-place -r ." 10 | }, 11 | "group": "build", 12 | "presentation": { 13 | "reveal": "silent", 14 | "clear": true 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | `casvantijn@gmail.com`. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). 126 | Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). 127 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Kapowarr 2 | ## General steps 3 | Contributing to Kapowarr consists of 5 steps, listed hereunder. 4 | 5 | 1. Make a [contributing request](https://github.com/Casvt/Kapowarr/issues/new?template=contribute_request.md), where you describe what you plan on doing. _This request needs to get approved before you can start._ The contributing request has multiple uses: 6 | 1. Avoid multiple people working on the same thing. 7 | 2. Avoid you wasting your time on changes that we do not wish for. 8 | 3. If needed, have discussions about how something will be implemented. 9 | 4. A place for contact, be it questions, status updates or something else. 10 | 2. When the request is accepted, start your local development (more info on this below). 11 | 3. When done, create a pull request to the development branch, where you quickly mention what has changed and give a link to the original contributing request issue. 12 | 4. The PR will be reviewed. Changes might need to be made in order for it to be merged. 13 | 5. When everything is okay, the PR will be accepted and you'll be done! 14 | 15 | ## Local development 16 | 17 | Once your contribution request has been accepted, you can start your local development. 18 | 19 | ### IDE 20 | 21 | It's up to you how you make the changes, but we use Visual Studio Code as the IDE. A workspace settings file is included that takes care of some styling, testing and formatting of the backend code. 22 | 23 | 1. The vs code extension `ms-python.vscode-pylance` in combination with the settings file with enable type checking. 24 | 2. The vs code extension `ms-python.mypy-type-checker` in combination with the settings file will enable mypy checking. 25 | 3. The vs code extension `ms-python.autopep8` in combination with the settings file will format code on save. 26 | 4. The vs code extension `ms-python.isort` in combination with the settings file will sort the import statements on save. 27 | 5. The settings file sets up the testing suite in VS Code such that you can just click the test button to run all tests. 28 | 29 | If you do not use VS Code with the mentioned extensions, then below are some commands that you can manually run in the base directory to achieve similar results. 30 | 31 | 1. **Mypy**: 32 | ```bash 33 | mypy --explicit-package-bases . 34 | ``` 35 | 2. **autopep8**: 36 | ```bash 37 | autopep8 --recursive --in-place . 38 | ``` 39 | 3. **isort**: 40 | ```bash 41 | isort . 42 | ``` 43 | 4. **unittest** 44 | ```bash 45 | python3 -m unittest discover -s ./tests -p '*.py' 46 | ``` 47 | 48 | ### Strict rules 49 | 50 | There are a few conditions that should always be met: 51 | 52 | 1. Kapowarr should support Python version 3.8 and higher. 53 | 2. Kapowarr should be compatible with Linux, MacOS, Windows and the Docker container. 54 | 3. The tests should all pass. 55 | 56 | ### Styling guide 57 | 58 | Following the styling guide for the backend code is not a strict rule, but effort should be put in to conform to it as much as possible. Running autopep8 and isort handles most of this. 59 | 60 | 1. Indentation is done with 4 spaces. Not using tabs. 61 | 2. Use type hints as much as possible. If you encounter an import loop because something needs to be imported for type hinting, utilise [`typing.TYPE_CHECKING`](https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING). 62 | 3. A function in the backend needs a doc string describing the function, what the inputs are, what errors could be raised from within the function and what the output is. 63 | 4. The imports need to be sorted. 64 | 5. The code should, though not strictly enforced, reasonably comply with the rule of 80 characters per line. 65 | 66 | ## A few miscellaneous notes 67 | 68 | 1. Kapowarr does not have many tests. They're not really required if you checked your changes for bugs already. But you are free to add tests for your changes anyway. 69 | 2. The function [`backend.base.file_extraction.extract_filename_data`](https://github.com/Casvt/Kapowarr/blob/eadc04d10b32c04d4bbc51d289d10cfa93bc44f6/backend/base/file_extraction.py#L186) and [the regexes defined at the top](https://github.com/Casvt/Kapowarr/blob/development/backend/base/file_extraction.py#L24-L55) that it uses have become a bit of a box of black magic. If the function does not work as expected, it might be best to just inform @Casvt in the contribution request issue and he'll try to fix it. 70 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM python:3.8-slim-buster 4 | STOPSIGNAL SIGTERM 5 | 6 | WORKDIR /app 7 | 8 | COPY requirements.txt requirements.txt 9 | RUN pip3 install --no-cache-dir -r requirements.txt 10 | 11 | COPY . . 12 | 13 | EXPOSE 5656 14 | 15 | CMD [ "python3", "/app/Kapowarr.py" ] 16 | -------------------------------------------------------------------------------- /Kapowarr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from argparse import ArgumentParser 5 | from atexit import register 6 | from multiprocessing import set_start_method 7 | from os import environ, name 8 | from signal import SIGINT, SIGTERM, signal 9 | from subprocess import Popen 10 | from sys import argv 11 | from typing import NoReturn, Union 12 | 13 | from backend.base.definitions import Constants, RestartVersion 14 | from backend.base.helpers import check_python_version, get_python_exe 15 | from backend.base.logging import LOGGER, setup_logging 16 | from backend.features.download_queue import DownloadHandler 17 | from backend.features.tasks import TaskHandler 18 | from backend.implementations.flaresolverr import FlareSolverr 19 | from backend.internals.db import set_db_location, setup_db 20 | from backend.internals.server import SERVER, handle_restart_version 21 | from backend.internals.settings import Settings 22 | 23 | 24 | def _main( 25 | restart_version: RestartVersion, 26 | db_folder: Union[str, None] = None 27 | ) -> NoReturn: 28 | """The main function of the Kapowarr sub-process 29 | 30 | Args: 31 | restart_version (RestartVersion): The type of (re)start. 32 | 33 | db_folder (Union[str, None], optional): The folder in which the database 34 | will be stored or in which a database is for Kapowarr to use. Give 35 | `None` for the default location. 36 | Defaults to None. 37 | 38 | Raises: 39 | ValueError: Value of `db_folder` exists but is not a folder. 40 | 41 | Returns: 42 | NoReturn: Exit code 0 means to shutdown. 43 | Exit code 131 or higher means to restart with possibly special reasons. 44 | """ 45 | set_start_method('spawn') 46 | setup_logging() 47 | LOGGER.info('Starting up Kapowarr') 48 | 49 | if not check_python_version(): 50 | exit(1) 51 | 52 | set_db_location(db_folder) 53 | 54 | SERVER.create_app() 55 | 56 | with SERVER.app.app_context(): 57 | handle_restart_version(restart_version) 58 | setup_db() 59 | 60 | settings = Settings().get_settings() 61 | flaresolverr = FlareSolverr() 62 | SERVER.set_url_base(settings.url_base) 63 | 64 | if settings.flaresolverr_base_url: 65 | flaresolverr.enable_flaresolverr(settings.flaresolverr_base_url) 66 | 67 | download_handler = DownloadHandler() 68 | download_handler.load_downloads() 69 | task_handler = TaskHandler() 70 | task_handler.handle_intervals() 71 | 72 | try: 73 | # ================= 74 | SERVER.run(settings.host, settings.port) 75 | # ================= 76 | 77 | finally: 78 | download_handler.stop_handle() 79 | task_handler.stop_handle() 80 | flaresolverr.disable_flaresolverr() 81 | 82 | if SERVER.restart_version is not None: 83 | LOGGER.info('Restarting Kapowarr') 84 | exit(SERVER.restart_version.value) 85 | 86 | exit(0) 87 | 88 | 89 | def _stop_sub_process(proc: Popen) -> None: 90 | """Gracefully stop the sub-process unless that fails. Then terminate it. 91 | 92 | Args: 93 | proc (Popen): The sub-process to stop. 94 | """ 95 | if proc.returncode is not None: 96 | return 97 | 98 | try: 99 | if name != 'nt': 100 | try: 101 | proc.send_signal(SIGINT) 102 | except ProcessLookupError: 103 | pass 104 | else: 105 | import win32api # type: ignore 106 | import win32con # type: ignore 107 | try: 108 | win32api.GenerateConsoleCtrlEvent( 109 | win32con.CTRL_C_EVENT, proc.pid 110 | ) 111 | except KeyboardInterrupt: 112 | pass 113 | except BaseException: 114 | proc.terminate() 115 | 116 | 117 | def _run_sub_process( 118 | restart_version: RestartVersion = RestartVersion.NORMAL 119 | ) -> int: 120 | """Start the sub-process that Kapowarr will be run in. 121 | 122 | Args: 123 | restart_version (RestartVersion, optional): Why Kapowarr was restarted. 124 | Defaults to `RestartVersion.NORMAL`. 125 | 126 | Returns: 127 | int: The return code from the sub-process. 128 | """ 129 | env = { 130 | **environ, 131 | "KAPOWARR_RUN_MAIN": "1", 132 | "KAPOWARR_RESTART_VERSION": str(restart_version.value) 133 | } 134 | 135 | comm = [get_python_exe(), "-u", __file__] + argv[1:] 136 | proc = Popen( 137 | comm, 138 | env=env 139 | ) 140 | proc._sigint_wait_secs = Constants.SUB_PROCESS_TIMEOUT # type: ignore 141 | register(_stop_sub_process, proc=proc) 142 | signal(SIGTERM, lambda signal_no, frame: _stop_sub_process(proc)) 143 | 144 | try: 145 | return proc.wait() 146 | except (KeyboardInterrupt, SystemExit, ChildProcessError): 147 | return 0 148 | 149 | 150 | def Kapowarr() -> int: 151 | """The main function of Kapowarr 152 | 153 | Returns: 154 | int: The return code. 155 | """ 156 | rc = RestartVersion.NORMAL.value 157 | while rc in RestartVersion._member_map_.values(): 158 | rc = _run_sub_process( 159 | RestartVersion(rc) 160 | ) 161 | 162 | return rc 163 | 164 | 165 | if __name__ == "__main__": 166 | if environ.get("KAPOWARR_RUN_MAIN") == "1": 167 | 168 | parser = ArgumentParser( 169 | description="Kapowarr is a software to build and manage a comic book library, fitting in the *arr suite of software.") 170 | parser.add_argument( 171 | '-d', '--DatabaseFolder', 172 | type=str, 173 | help="The folder in which the database will be stored or in which a database is for Kapowarr to use" 174 | ) 175 | args = parser.parse_args() 176 | db_folder = args.DatabaseFolder 177 | 178 | rv = RestartVersion(int(environ.get( 179 | "KAPOWARR_RESTART_VERSION", 180 | RestartVersion.NORMAL.value 181 | ))) 182 | 183 | try: 184 | _main( 185 | restart_version=rv, 186 | db_folder=db_folder 187 | ) 188 | 189 | except ValueError as e: 190 | if e.args and e.args[0] == 'Database location is not a folder': 191 | parser.error( 192 | "The value for -d/--DatabaseFolder is not a folder" 193 | ) 194 | else: 195 | raise e 196 | 197 | else: 198 | rc = Kapowarr() 199 | exit(rc) 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kapowarr 2 | 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/mrcas/kapowarr.svg)](https://hub.docker.com/r/mrcas/kapowarr) 4 | 5 | Kapowarr is a software to build and manage a comic book library, fitting in the *arr suite of software. 6 | 7 | Kapowarr allows you to build a digital library of comics. You can add volumes, map them to a folder and start managing! Download, rename, move and convert issues of the volume (including TPB's, One Shots, Hard Covers, and more). The whole process is automated and can be customised in the settings. 8 | 9 | ## Features 10 | 11 | - Support for all major OS'es 12 | - Import your current library right into Kapowarr 13 | - Get loads of metadata about the volumes and issues in your library 14 | - Run a "Search Monitored" to download whole volumes with one click 15 | - Or use "Manual Search" to decide yourself what to download 16 | - Support for downloading directly, or via MediaFire, Mega and many other services 17 | - Downloaded files automatically get moved wherever you want and renamed in the format you desire 18 | - Archive files can be extracted and it's contents renamed after downloading or with a single click 19 | - The recognisable UI from the *arr suite of software 20 | 21 | ## Installation, support and documentation 22 | 23 | - For instructions on how to install Kapowarr, see the [installation documentation](https://casvt.github.io/Kapowarr/installation/installation/). 24 | - For support, a [Discord server](https://discord.gg/nMNdgG7vsE) and [subreddit](https://www.reddit.com/r/kapowarr/) are available, or [make an issue](https://github.com/Casvt/Kapowarr/issues). 25 | - For all documentation, see the [documentation hub](https://casvt.github.io/Kapowarr/). 26 | 27 | ## Screenshots 28 | 29 | ![](https://github.com/user-attachments/assets/04656209-288e-4263-a2df-93e06758c443) 30 | ![](https://github.com/user-attachments/assets/3fa8177c-f016-4cbd-b73e-6b577840b08e) 31 | ![](https://github.com/user-attachments/assets/69d59c21-3983-4acc-8777-ae0c7b65fdff) 32 | ![](https://github.com/user-attachments/assets/6e26c4e9-3c75-4b2c-b853-9fe2b56c9617) 33 | -------------------------------------------------------------------------------- /backend/base/logging.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import logging.config 5 | from logging.handlers import RotatingFileHandler 6 | from typing import Any, Union 7 | 8 | from backend.base.definitions import Constants 9 | 10 | 11 | class UpToInfoFilter(logging.Filter): 12 | def filter(self, record: logging.LogRecord) -> bool: 13 | return record.levelno <= logging.INFO 14 | 15 | 16 | class ErrorColorFormatter(logging.Formatter): 17 | def format(self, record: logging.LogRecord) -> Any: 18 | result = super().format(record) 19 | return f'\033[1;31:40m{result}\033[0m' 20 | 21 | 22 | class MPRotatingFileHandler(RotatingFileHandler): 23 | def __init__(self, 24 | filename, 25 | mode="a", 26 | maxBytes=0, 27 | backupCount=0, 28 | encoding=None, 29 | delay=False, 30 | do_rollover=True 31 | ) -> None: 32 | self.do_rollover = do_rollover 33 | return super().__init__(filename, mode, maxBytes, backupCount, encoding, delay) 34 | 35 | def shouldRollover(self, record: logging.LogRecord) -> int: 36 | if not self.do_rollover: 37 | return 0 38 | return super().shouldRollover(record) 39 | 40 | 41 | LOGGER = logging.getLogger(Constants.LOGGER_NAME) 42 | LOGGING_CONFIG = { 43 | "version": 1, 44 | "disable_existing_loggers": False, 45 | "formatters": { 46 | "simple": { 47 | "format": "[%(asctime)s][%(levelname)s] %(message)s", 48 | "datefmt": "%H:%M:%S" 49 | }, 50 | "simple_red": { 51 | "()": ErrorColorFormatter, 52 | "format": "[%(asctime)s][%(levelname)s] %(message)s", 53 | "datefmt": "%H:%M:%S" 54 | }, 55 | "detailed": { 56 | "format": "%(asctime)s | %(processName)s | %(threadName)s | %(filename)sL%(lineno)s | %(levelname)s | %(message)s", 57 | "datefmt": "%Y-%m-%dT%H:%M:%S%z", 58 | } 59 | }, 60 | "filters": { 61 | "up_to_info": { 62 | "()": UpToInfoFilter 63 | } 64 | }, 65 | "handlers": { 66 | "console_error": { 67 | "class": "logging.StreamHandler", 68 | "level": "WARNING", 69 | "formatter": "simple_red", 70 | "stream": "ext://sys.stderr" 71 | }, 72 | "console": { 73 | "class": "logging.StreamHandler", 74 | "level": "DEBUG", 75 | "formatter": "simple", 76 | "filters": ["up_to_info"], 77 | "stream": "ext://sys.stdout" 78 | }, 79 | "file": { 80 | "()": MPRotatingFileHandler, 81 | "level": "DEBUG", 82 | "formatter": "detailed", 83 | "filename": "", 84 | "maxBytes": 1_000_000, 85 | "backupCount": 1, 86 | "do_rollover": True 87 | } 88 | }, 89 | "loggers": { 90 | Constants.LOGGER_NAME: {} 91 | }, 92 | "root": { 93 | "level": "INFO", 94 | "handlers": [ 95 | "console", 96 | "console_error", 97 | "file" 98 | ] 99 | } 100 | } 101 | 102 | 103 | def setup_logging(do_rollover: bool = True) -> None: 104 | "Setup the basic config of the logging module" 105 | 106 | LOGGING_CONFIG["handlers"]["file"]["filename"] = get_log_filepath() 107 | LOGGING_CONFIG["handlers"]["file"]["do_rollover"] = do_rollover 108 | 109 | logging.config.dictConfig(LOGGING_CONFIG) 110 | 111 | # Log uncaught exceptions using the logger instead of printing the stderr 112 | # Logger goes to stderr anyway, so still visible in console but also logs 113 | # to file, so that downloaded log file also contains any errors. 114 | import sys 115 | import threading 116 | from traceback import format_exception 117 | 118 | def log_uncaught_exceptions(e_type, value, tb): 119 | LOGGER.error( 120 | "UNCAUGHT EXCEPTION:\n" + 121 | ''.join(format_exception(e_type, value, tb)) 122 | ) 123 | return 124 | 125 | def log_uncaught_threading_exceptions(args): 126 | LOGGER.exception( 127 | f"UNCAUGHT EXCEPTION IN THREAD: {args.exc_value}" 128 | ) 129 | return 130 | 131 | sys.excepthook = log_uncaught_exceptions 132 | threading.excepthook = log_uncaught_threading_exceptions 133 | 134 | return 135 | 136 | 137 | def get_log_filepath() -> str: 138 | """ 139 | Get the filepath to the logging file. 140 | Not in a global variable to avoid unnecessary computation. 141 | """ 142 | from backend.base.files import folder_path 143 | return folder_path(Constants.LOGGER_FILENAME) 144 | 145 | 146 | def set_log_level( 147 | level: Union[int, str] 148 | ) -> None: 149 | """Change the logging level. 150 | 151 | Args: 152 | level (Union[int, str]): The level to set the logging to. 153 | Should be a logging level, like `logging.INFO` or `"DEBUG"`. 154 | """ 155 | if isinstance(level, str): 156 | level = logging._nameToLevel[level.upper()] 157 | 158 | root_logger = logging.getLogger() 159 | if root_logger.level == level: 160 | return 161 | 162 | LOGGER.debug(f'Setting logging level: {level}') 163 | root_logger.setLevel(level) 164 | 165 | return 166 | -------------------------------------------------------------------------------- /backend/features/mass_edit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import List 4 | 5 | from backend.base.custom_exceptions import (InvalidKeyValue, KeyNotFound, 6 | VolumeDownloadedFor) 7 | from backend.base.definitions import MassEditorAction, MonitorScheme 8 | from backend.base.helpers import get_subclasses 9 | from backend.base.logging import LOGGER 10 | from backend.features.download_queue import DownloadHandler 11 | from backend.features.search import auto_search 12 | from backend.implementations.conversion import mass_convert 13 | from backend.implementations.naming import mass_rename 14 | from backend.implementations.root_folders import RootFolders 15 | from backend.implementations.volumes import Volume, refresh_and_scan 16 | from backend.internals.db import iter_commit 17 | 18 | 19 | class MassEditorDelete(MassEditorAction): 20 | identifier = 'delete' 21 | 22 | def run(self, **kwargs) -> None: 23 | delete_volume_folder = kwargs.get('delete_folder', False) 24 | if not isinstance(delete_volume_folder, bool): 25 | raise InvalidKeyValue('delete_folder', delete_volume_folder) 26 | 27 | LOGGER.info(f'Using mass editor, deleting volumes: {self.volume_ids}') 28 | 29 | for volume_id in iter_commit(self.volume_ids): 30 | try: 31 | Volume(volume_id).delete(delete_volume_folder) 32 | except VolumeDownloadedFor: 33 | continue 34 | return 35 | 36 | 37 | class MassEditorRootFolder(MassEditorAction): 38 | identifier = 'root_folder' 39 | 40 | def run(self, **kwargs) -> None: 41 | root_folder_id = kwargs.get('root_folder_id') 42 | if root_folder_id is None: 43 | raise KeyNotFound('root_folder_id') 44 | if not isinstance(root_folder_id, int): 45 | raise InvalidKeyValue('root_folder_id', root_folder_id) 46 | # Raises RootFolderNotFound if ID is invalid 47 | RootFolders().get_one(root_folder_id) 48 | 49 | LOGGER.info( 50 | f'Using mass editor, settings root folder to {root_folder_id} for volumes: {self.volume_ids}' 51 | ) 52 | 53 | for volume_id in iter_commit(self.volume_ids): 54 | Volume(volume_id).change_root_folder(root_folder_id) 55 | 56 | return 57 | 58 | 59 | class MassEditorRename(MassEditorAction): 60 | identifier = 'rename' 61 | 62 | def run(self, **kwargs) -> None: 63 | LOGGER.info(f'Using mass editor, renaming volumes: {self.volume_ids}') 64 | for volume_id in iter_commit(self.volume_ids): 65 | mass_rename(volume_id) 66 | return 67 | 68 | 69 | class MassEditorUpdate(MassEditorAction): 70 | identifier = 'update' 71 | 72 | def run(self, **kwargs) -> None: 73 | LOGGER.info(f'Using mass editor, updating volumes: {self.volume_ids}') 74 | for volume_id in iter_commit(self.volume_ids): 75 | refresh_and_scan(volume_id) 76 | return 77 | 78 | 79 | class MassEditorSearch(MassEditorAction): 80 | identifier = 'search' 81 | 82 | def run(self, **kwargs) -> None: 83 | LOGGER.info( 84 | f'Using mass editor, auto searching for volumes: {self.volume_ids}' 85 | ) 86 | download_handler = DownloadHandler() 87 | 88 | for volume_id in self.volume_ids: 89 | search_results = auto_search(volume_id) 90 | download_handler.add_multiple( 91 | (result['link'], volume_id, None, False) 92 | for result in search_results 93 | ) 94 | 95 | return 96 | 97 | 98 | class MassEditorConvert(MassEditorAction): 99 | identifier = 'convert' 100 | 101 | def run(self, **kwargs) -> None: 102 | LOGGER.info( 103 | f'Using mass editor, converting for volumes: {self.volume_ids}') 104 | for volume_id in iter_commit(self.volume_ids): 105 | mass_convert(volume_id) 106 | return 107 | 108 | 109 | class MassEditorUnmonitor(MassEditorAction): 110 | identifier = 'unmonitor' 111 | 112 | def run(self, **kwargs) -> None: 113 | LOGGER.info( 114 | f'Using mass editor, unmonitoring volumes: {self.volume_ids}') 115 | for volume_id in self.volume_ids: 116 | Volume(volume_id)['monitored'] = False 117 | return 118 | 119 | 120 | class MassEditorMonitor(MassEditorAction): 121 | identifier = 'monitor' 122 | 123 | def run(self, **kwargs) -> None: 124 | LOGGER.info(f'Using mass editor, monitoring volumes: {self.volume_ids}') 125 | for volume_id in self.volume_ids: 126 | Volume(volume_id)['monitored'] = True 127 | return 128 | 129 | 130 | class MassEditorMonitoringScheme(MassEditorAction): 131 | identifier = 'monitoring_scheme' 132 | 133 | def run(self, **kwargs) -> None: 134 | monitoring_scheme = kwargs.get('monitoring_scheme') 135 | if monitoring_scheme is None: 136 | raise KeyNotFound('monitoring_scheme') 137 | try: 138 | monitoring_scheme = MonitorScheme(monitoring_scheme) 139 | except ValueError: 140 | raise InvalidKeyValue('monitoring_scheme', monitoring_scheme) 141 | 142 | LOGGER.info( 143 | f'Using mass editor, applying monitoring scheme "{monitoring_scheme.value}" for volumes: {self.volume_ids}' 144 | ) 145 | 146 | for volume_id in self.volume_ids: 147 | Volume(volume_id).apply_monitor_scheme(monitoring_scheme) 148 | 149 | return 150 | 151 | 152 | def run_mass_editor_action( 153 | action: str, 154 | volume_ids: List[int], 155 | **kwargs 156 | ) -> None: 157 | """Run a mass editor action. 158 | 159 | Args: 160 | action (str): The action to run. 161 | volume_ids (List[int]): The volume IDs to run the action on. 162 | **kwargs (Dict[str, Any]): The arguments to pass to the action. 163 | 164 | Raises: 165 | InvalidKeyValue: If the action or any argument is not valid. 166 | """ 167 | for ActionClass in get_subclasses(MassEditorAction): 168 | if ActionClass.identifier == action: 169 | break 170 | else: 171 | raise InvalidKeyValue('action', action) 172 | 173 | ActionClass(volume_ids).run(**kwargs) 174 | return 175 | -------------------------------------------------------------------------------- /backend/implementations/blocklist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from time import time 4 | from typing import List, Union 5 | 6 | from backend.base.custom_exceptions import BlocklistEntryNotFound 7 | from backend.base.definitions import (BlocklistEntry, BlocklistReason, 8 | BlocklistReasonID, DownloadSource, 9 | GCDownloadSource) 10 | from backend.base.logging import LOGGER 11 | from backend.internals.db import get_db 12 | 13 | 14 | # region Get 15 | def get_blocklist(offset: int = 0) -> List[BlocklistEntry]: 16 | """Get the blocklist entries in blocks of 50. 17 | 18 | Args: 19 | offset (int, optional): The offset of the list. 20 | The higher the number, the deeper into the list you go. 21 | 22 | Defaults to 0. 23 | 24 | Returns: 25 | List[BlocklistEntry]: A list of the current entries in the blocklist. 26 | """ 27 | entries = get_db().execute(""" 28 | SELECT 29 | id, volume_id, issue_id, 30 | web_link, web_title, web_sub_title, 31 | download_link, source, 32 | reason, added_at 33 | FROM blocklist 34 | ORDER BY id DESC 35 | LIMIT 50 36 | OFFSET ?; 37 | """, 38 | (offset * 50,) 39 | ).fetchalldict() 40 | 41 | result = [ 42 | BlocklistEntry(**{ 43 | **entry, 44 | "reason": BlocklistReason[ 45 | BlocklistReasonID(entry["reason"]).name 46 | ] 47 | }) 48 | for entry in entries 49 | ] 50 | 51 | return result 52 | 53 | 54 | def get_blocklist_entry(id: int) -> BlocklistEntry: 55 | """Get info about a blocklist entry. 56 | 57 | Args: 58 | id (int): The id of the blocklist entry. 59 | 60 | Raises: 61 | BlocklistEntryNotFound: The id doesn't map to any blocklist entry. 62 | 63 | Returns: 64 | BlocklistEntry: The info of the blocklist entry. 65 | """ 66 | entry = get_db().execute(""" 67 | SELECT 68 | id, volume_id, issue_id, 69 | web_link, web_title, web_sub_title, 70 | download_link, source, 71 | reason, added_at 72 | FROM blocklist 73 | WHERE id = ? 74 | LIMIT 1; 75 | """, 76 | (id,) 77 | ).fetchonedict() 78 | 79 | if not entry: 80 | raise BlocklistEntryNotFound 81 | 82 | return BlocklistEntry(**{ 83 | **entry, 84 | "reason": BlocklistReason[ 85 | BlocklistReasonID(entry["reason"]).name 86 | ] 87 | }) 88 | 89 | 90 | # region Contains and Add 91 | def blocklist_contains(link: str) -> Union[int, None]: 92 | """Check if a link is in the blocklist. 93 | 94 | Args: 95 | link (str): The link to check for. 96 | 97 | Returns: 98 | Union[int, None]: The ID of the blocklist entry, if found. Otherwise 99 | `None`. 100 | """ 101 | result = get_db().execute(""" 102 | SELECT id 103 | FROM blocklist 104 | WHERE download_link = ? 105 | OR (web_link = ? AND download_link IS NULL) 106 | LIMIT 1; 107 | """, 108 | (link, link) 109 | ).exists() 110 | return result 111 | 112 | 113 | def add_to_blocklist( 114 | web_link: Union[str, None], 115 | web_title: Union[str, None], 116 | 117 | web_sub_title: Union[str, None], 118 | download_link: Union[str, None], 119 | source: Union[DownloadSource, GCDownloadSource, None], 120 | 121 | volume_id: int, 122 | issue_id: Union[int, None], 123 | 124 | reason: BlocklistReason 125 | ) -> BlocklistEntry: 126 | """Add a link to the blocklist. 127 | 128 | Args: 129 | web_link (Union[str, None]): The link to the GC page. 130 | 131 | web_title (Union[str, None]): The title of the GC release. 132 | 133 | web_sub_title (Union[str, None]): The name of the download group on the 134 | GC page. 135 | 136 | download_link (str): The link to block. Give `None` to block the whole 137 | GC page (`web_link`). 138 | 139 | source (Union[DownloadSource, GCDownloadSource, None]): The source of 140 | the download. 141 | 142 | volume_id (int): The ID of the volume for which this link is 143 | blocklisted. 144 | 145 | issue_id (Union[int, None]): The ID of the issue for which this link is 146 | blocklisted, if the link is for a specific issue. 147 | 148 | reason (BlocklistReasons): The reason why the link is blocklisted. 149 | See `backend.enums.BlocklistReason`. 150 | 151 | Returns: 152 | BlocklistEntry: Info about the blocklist entry. 153 | """ 154 | # Select link to blocklist 155 | blocked_link = download_link or web_link 156 | if not blocked_link: 157 | raise ValueError("No page link or download link supplied") 158 | 159 | # Stop if it's already added 160 | id = blocklist_contains(blocked_link) 161 | if id: 162 | return get_blocklist_entry(id) 163 | 164 | # Add to database 165 | LOGGER.info( 166 | f'Adding {blocked_link} to blocklist with reason "{reason.value}"' 167 | ) 168 | 169 | reason_id = BlocklistReasonID[reason.name].value 170 | source_value = source.value if source is not None else None 171 | id = get_db().execute(""" 172 | INSERT INTO blocklist( 173 | volume_id, issue_id, 174 | web_link, web_title, web_sub_title, 175 | download_link, source, 176 | reason, added_at 177 | ) 178 | VALUES ( 179 | :volume_id, :issue_id, 180 | :web_link, :web_title, :web_sub_title, 181 | :download_link, :source, 182 | :reason, :added_at 183 | ); 184 | """, 185 | { 186 | "volume_id": volume_id, 187 | "issue_id": issue_id, 188 | "web_link": web_link, 189 | "web_title": web_title, 190 | "web_sub_title": web_sub_title, 191 | "download_link": download_link, 192 | "source": source_value, 193 | "reason": reason_id, 194 | "added_at": round(time()) 195 | } 196 | ).lastrowid 197 | 198 | return get_blocklist_entry(id) 199 | 200 | 201 | # region Delete 202 | def delete_blocklist() -> None: 203 | """Delete all blocklist entries.""" 204 | LOGGER.info('Deleting blocklist') 205 | get_db().execute( 206 | "DELETE FROM blocklist;" 207 | ) 208 | return 209 | 210 | 211 | def delete_blocklist_entry(id: int) -> None: 212 | """Delete a blocklist entry. 213 | 214 | Args: 215 | id (int): The id of the blocklist entry. 216 | 217 | Raises: 218 | BlocklistEntryNotFound: The id doesn't map to any blocklist entry. 219 | """ 220 | LOGGER.debug(f'Deleting blocklist entry {id}') 221 | 222 | entry_found = get_db().execute( 223 | "DELETE FROM blocklist WHERE id = ?", 224 | (id,) 225 | ).rowcount 226 | 227 | if not entry_found: 228 | raise BlocklistEntryNotFound 229 | 230 | return 231 | -------------------------------------------------------------------------------- /backend/implementations/credentials.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Any, Dict, List, Tuple 4 | 5 | from typing_extensions import assert_never 6 | 7 | from backend.base.custom_exceptions import (ClientNotWorking, 8 | CredentialInvalid, 9 | CredentialNotFound) 10 | from backend.base.definitions import CredentialData, CredentialSource 11 | from backend.base.logging import LOGGER 12 | from backend.internals.db import get_db 13 | 14 | 15 | class Credentials: 16 | auth_tokens: Dict[CredentialSource, Dict[str, Tuple[Any, int]]] = {} 17 | """ 18 | Store auth tokens as to avoid logging in while already having a cleared 19 | token. Maps from credential source to user identifier (something like user 20 | ID, email or username) to a tuple of the token and it's expiration time. 21 | """ 22 | 23 | def get_all(self) -> List[CredentialData]: 24 | """Get all credentials. 25 | 26 | Returns: 27 | List[CredentialData]: The list of credentials. 28 | """ 29 | return [ 30 | CredentialData(**{ 31 | **dict(c), 32 | 'source': CredentialSource[c["source"].upper()] 33 | }) 34 | for c in get_db().execute(""" 35 | SELECT 36 | id, source, 37 | username, email, 38 | password, api_key 39 | FROM credentials; 40 | """).fetchall() 41 | ] 42 | 43 | def get_one(self, id: int) -> CredentialData: 44 | """Get a credential based on it's id. 45 | 46 | Args: 47 | id (int): The ID of the credential to get. 48 | 49 | Raises: 50 | CredentialNotFound: The ID doesn't map to any credential. 51 | 52 | Returns: 53 | CredentialData: The credential info 54 | """ 55 | result = get_db().execute(""" 56 | SELECT 57 | id, source, 58 | username, email, 59 | password, api_key 60 | FROM credentials 61 | WHERE id = ? 62 | LIMIT 1; 63 | """, 64 | (id,) 65 | ).fetchone() 66 | 67 | if result is None: 68 | raise CredentialNotFound 69 | 70 | return CredentialData(**{ 71 | **dict(result), 72 | 'source': CredentialSource(result["source"]) 73 | }) 74 | 75 | def get_from_source( 76 | self, 77 | source: CredentialSource 78 | ) -> List[CredentialData]: 79 | """Get credentials for the given source. 80 | 81 | Args: 82 | source (CredentialSource): The source of the credentials. 83 | 84 | Returns: 85 | List[CredentialData]: The credentials for the given source. 86 | """ 87 | return [ 88 | c 89 | for c in self.get_all() 90 | if c.source == source 91 | ] 92 | 93 | def add(self, credential_data: CredentialData) -> CredentialData: 94 | """Add a credential. 95 | 96 | Args: 97 | credential_data (CredentialData): The data of the credential to 98 | store. 99 | 100 | Raises: 101 | CredentialInvalid: The credential data is invalid. 102 | 103 | Returns: 104 | CredentialData: The credential info 105 | """ 106 | LOGGER.info(f'Adding credential for {credential_data.source.value}') 107 | 108 | # Check if it works 109 | if credential_data.source == CredentialSource.MEGA: 110 | from backend.implementations.direct_clients.mega import ( 111 | MegaAccount, MegaAPIClient) 112 | 113 | try: 114 | MegaAccount( 115 | MegaAPIClient(), 116 | credential_data.email or '', 117 | credential_data.password or '' 118 | ) 119 | 120 | except ClientNotWorking as e: 121 | raise CredentialInvalid(e.desc) 122 | 123 | credential_data.api_key = None 124 | credential_data.username = None 125 | 126 | elif credential_data.source == CredentialSource.PIXELDRAIN: 127 | from backend.implementations.download_clients import \ 128 | PixelDrainDownload 129 | 130 | try: 131 | result = PixelDrainDownload.login( 132 | credential_data.api_key or '' 133 | ) 134 | if result == -1: 135 | raise ClientNotWorking("Failed to login into Pixeldrain") 136 | 137 | except ClientNotWorking as e: 138 | raise CredentialInvalid(e.desc) 139 | 140 | credential_data.email = None 141 | credential_data.username = None 142 | credential_data.password = None 143 | 144 | else: 145 | assert_never(credential_data.source) 146 | 147 | id = get_db().execute(""" 148 | INSERT INTO credentials(source, username, email, password, api_key) 149 | VALUES (:source, :username, :email, :password, :api_key); 150 | """, 151 | credential_data.as_dict() 152 | ).lastrowid 153 | 154 | return self.get_one(id) 155 | 156 | def delete(self, cred_id: int) -> None: 157 | """Delete a credential. 158 | 159 | Args: 160 | cred_id (int): The ID of the credential to delete. 161 | 162 | Raises: 163 | CredentialNotFound: The ID doesn't map to any credential. 164 | """ 165 | LOGGER.info(f'Deleting credential: {cred_id}') 166 | 167 | source = self.get_one(cred_id).source 168 | 169 | get_db().execute( 170 | "DELETE FROM credentials WHERE id = ?", (cred_id,) 171 | ) 172 | 173 | if source in self.auth_tokens: 174 | del self.auth_tokens[source] 175 | 176 | return 177 | -------------------------------------------------------------------------------- /backend/lib/rar_bsd_64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/backend/lib/rar_bsd_64 -------------------------------------------------------------------------------- /backend/lib/rar_linux_64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/backend/lib/rar_linux_64 -------------------------------------------------------------------------------- /backend/lib/rar_windows_64.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/backend/lib/rar_windows_64.exe -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | kapowarr: 4 | container_name: kapowarr 5 | image: mrcas/kapowarr:latest 6 | volumes: 7 | - "kapowarr-db:/app/db" 8 | - "/path/to/download_folder:/app/temp_downloads" 9 | - "/path/to/root_folder:/comics-1" 10 | ports: 11 | - 5656:5656 12 | 13 | volumes: 14 | kapowarr-db: 15 | -------------------------------------------------------------------------------- /docs/assets/css/extra.css: -------------------------------------------------------------------------------- 1 | /* */ 2 | /* Color defined */ 3 | /* */ 4 | body { 5 | --light-color: #ffffff; 6 | --off-light-color: #f6f6f6; 7 | --dark-hover-color: #f9f9f9; 8 | --border-color: #dde6e9; 9 | --mid-color: #808080; 10 | --dark-color: #464b51; 11 | 12 | --accent-color: #ebc700; 13 | --success-color: #54db68; 14 | --error-color: #db5461; 15 | 16 | --background-color: var(--off-light-color); 17 | --text-color: var(--dark-color); 18 | --dark-text-color: var(--mid-color); 19 | --light-text-color: var(--border-color); 20 | --header-background-color: var(--accent-color); 21 | --header-color: var(--dark-color); 22 | --code-background-color: var(--mid-color); 23 | --search-color: var(--dark-color); 24 | } 25 | 26 | [data-md-color-scheme="Kapowarr-dark"] { 27 | --light-color: #dde6e9; 28 | --off-light-color: #c5c5c5; 29 | --border-color: #7f7f7f; 30 | --gray-color: #2a2a2a; 31 | --mid-color: #262626; 32 | --dark-color: #202020; 33 | --dark-hover-color: var(--mid-color); 34 | 35 | --light-accent-color: var(--border-color); 36 | 37 | --background-color: var(--dark-color); 38 | --text-color: var(--light-color); 39 | --dark-text-color: var(--off-light-color); 40 | --light-text-color: var(--light-color); 41 | --header-background-color: var(--accent-color); 42 | --header-color: var(--gray-color); 43 | --code-background-color: var(--gray-color); 44 | --search-color: var(--text-color); 45 | } 46 | 47 | /* */ 48 | /* Color applied */ 49 | /* */ 50 | body[data-md-color-scheme="Kapowarr"], 51 | body[data-md-color-scheme="Kapowarr-dark"] { 52 | /* Background color of header */ 53 | --md-primary-fg-color: var(--header-background-color); 54 | /* Color in header */ 55 | --md-primary-bg-color: var(--header-color); 56 | 57 | /* Background color */ 58 | --md-default-bg-color: var(--background-color); 59 | /* Text color */ 60 | --md-typeset-color: var(--text-color); 61 | 62 | /* Nav header and unselected tab color */ 63 | --md-default-fg-color--light: var(--dark-text-color); 64 | --md-primary-fg-color--lighter: #00000052; 65 | 66 | /* Horizontal lines */ 67 | --md-default-fg-color--lightest: var(--light-text-color); 68 | 69 | /* Placeholder of search color */ 70 | --md-primary-bg-color--light: var(--md-primary-bg-color); 71 | 72 | /* Color in search */ 73 | --md-default-fg-color: var(--search-color); 74 | 75 | /* Hover color selected tab, ToC, Permanent Link hover and color of matched words in search */ 76 | --md-accent-fg-color: var(--accent-color); 77 | 78 | /* Code color */ 79 | --md-code-fg-color: var(--light-text-color); 80 | /* Code background color */ 81 | --md-code-bg-color: var(--code-background-color); 82 | --md-code-hl-color: #ffff0080; 83 | --md-code-hl-number-color: #fa3333; 84 | --md-code-hl-special-color: #db1457; 85 | --md-code-hl-function-color: #a846b9; 86 | --md-code-hl-constant-color: #6e59d9; 87 | --md-code-hl-keyword-color: #3f6ec6; 88 | --md-code-hl-string-color: #1c7d4d; 89 | --md-code-hl-string-color: #2ecd7d; 90 | 91 | /* Slashes in code */ 92 | --md-code-hl-name-color: var(--md-code-fg-color); 93 | --md-code-hl-operator-color: var(--md-code-fg-color); 94 | --md-code-hl-punctuation-color: var(--md-code-fg-color); 95 | --md-code-hl-comment-color: var(--md-code-fg-color); 96 | --md-code-hl-generic-color: var(--md-code-fg-color); 97 | --md-code-hl-variable-color: var(--md-code-fg-color); 98 | 99 | /* Link color */ 100 | --md-typeset-a-color: var(--accent-color); 101 | --md-typeset-mark-color: #ffff0080; 102 | --md-typeset-del-color: #f5503d26; 103 | --md-typeset-ins-color: #0bd57026; 104 | --md-typeset-kbd-color: #fafafa; 105 | --md-typeset-kbd-accent-color: #fff; 106 | --md-typeset-kbd-border-color: #b8b8b8; 107 | --md-typeset-table-color: var(--dark-text-color); 108 | --md-typeset-table-color--light: rgba(0,0,0,.035); 109 | 110 | --md-admonition-fg-color: var(--text-color); 111 | --md-admonition-bg-color: var(--background-color); 112 | 113 | --md-warning-fg-color: #000000de; 114 | --md-warning-bg-color: #ff9; 115 | 116 | /* Footer color */ 117 | --md-footer-fg-color: var(--light-text-color); 118 | /* Footer background color */ 119 | --md-footer-bg-color: var(--background-color); 120 | 121 | --md-shadow-z1: 0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a; 122 | --md-shadow-z2: 0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040; 123 | --md-shadow-z3: 0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059; 124 | } 125 | 126 | .md-typeset .tabbed-labels { 127 | --md-default-fg-color: var(--accent-color); 128 | } 129 | 130 | .md-typeset code { 131 | border-radius: 6px; 132 | } 133 | 134 | .md-typeset a { 135 | text-decoration: underline; 136 | } 137 | 138 | .md-typeset a.headerlink { 139 | text-decoration: none; 140 | } 141 | 142 | .md-typeset div.tabbed-labels a { 143 | text-decoration: none; 144 | } 145 | 146 | .md-clipboard { 147 | color: var(--code-background-color); 148 | } 149 | 150 | :hover > .md-clipboard { 151 | color: var(--light-text-color); 152 | } 153 | 154 | .md-clipboard:hover { 155 | color: var(--accent-color); 156 | } 157 | 158 | .md-typeset table:not([class]) { 159 | border-radius: 6px; 160 | } 161 | 162 | [data-md-component="search-result"] > div { 163 | background-color: var(--accent-color); 164 | color: var(--header-color); 165 | } 166 | 167 | .md-nav--primary .md-nav__title { 168 | --md-default-fg-color--lightest: var(--md-default-bg-color); 169 | --md-default-fg-color--lightest: var(--md-primary-fg-color); 170 | --md-default-fg-color--light: var(--md-primary-bg-color); 171 | } 172 | 173 | .md-nav__source { 174 | background-color: var(--background-color); 175 | color: var(--text-color); 176 | } 177 | 178 | details { 179 | border-color: var(--header-color); 180 | } 181 | 182 | details > summary { 183 | background-color: transparent; 184 | } 185 | 186 | details > summary::before { 187 | background-color: var(--header-color); 188 | } 189 | -------------------------------------------------------------------------------- /docs/assets/img/Docker_Desktop_setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/docs/assets/img/Docker_Desktop_setup.png -------------------------------------------------------------------------------- /docs/beta/beta.md: -------------------------------------------------------------------------------- 1 | # Documentation for Beta Releases 2 | 3 | Here you can find documentation on changes and additions that are only present in the beta release. Currently, there is no beta release. 4 | -------------------------------------------------------------------------------- /docs/general_info/downloading.md: -------------------------------------------------------------------------------- 1 | # Downloading 2 | 3 | When a GetComics (GC) release has been chosen, either by you or by Kapowarr, Kapowarr will try to find a download link (or multiple) on the page to use for downloading the file(s). How Kapowarr decides which link(s) to use, is broken down in the following steps: 4 | 5 | 1. GC often offers multiple services for the same download. Kapowarr first analyses the page and collects all groups and their links (one group per download). 6 | 2. Next, it will combine multiple groups to cover as many issues as possible (if the download is for more than one issue). This collection of groups will form a 'path'. Groups are only added to a path if Kapowarr thinks that the group is actually downloading something we want. There can be multiple paths, in the case where there are conflicting groups. For example when there is a torrent download for all issues and multiple other downloads for issues 1-10, 11-20, etc. 7 | 3. All the links in the first path are tested, to see if they work, are supported and are allowed by the blocklist. A link could not be supported because the service is simply not supported, or because it's a torrent download and no torrent client is set up. If enough links pass, all the downloads in the path are added to the queue to be downloaded. If not enough links pass, the next path is tested until one is found that passes. If none pass, the page is added to the blocklist for having no working links. 8 | 4. When a path passes and it's groups are added to the download queue, a decision needs to be made for which service is going to be used for each group (direct download, Mega, MediaFire, etc.). The ['Service Preference' setting](../settings/download.md#service-preference) decides this. 9 | 10 | If Kapowarr downloads less (or nothing at all) from a page, but you are convinced that that shouldn't be the case, read about this topic [on the FAQ page](../other_docs/faq.md#why-does-kapowarr-not-grab-the-links-from-a-gc-page-even-though-they-work-fine). 11 | -------------------------------------------------------------------------------- /docs/general_info/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ## Library Import 4 | 5 | The feature 'Library Import' makes it possible to import an existing library into Kapowarr. You could have an existing library because you used a different software before, or because you downloaded media manually. In that case, Library Import makes it easy to start using Kapowarr. 6 | 7 | ### Proposal 8 | 9 | When you run Library Import, it will search for files in your root folders that aren't matched to any issues yet. It will then try to find the volume for the file on ComicVine. This list of files and ComicVine matches is presented to you (a.k.a. Library Import proposal). You can then change the matches in case Kapowarr guessed incorrectly. You can choose to apply the changed match to only the file, or to all files for the volume. 10 | 11 | On the start screen, there are some settings that change the behaviour of Library Import: 12 | 13 | - **Max folders scanned**: Limit the proposal to this amount of folders (roughly equal to the amount of volumes). Setting this to a large amount increases the chance of hitting the [CV rate limit](../other_docs/rate_limiting.md). 14 | - **Apply limit to parent folder**: Apply the folder limit (see previous bullet) to the parent folder instead of the folder. Enable this when each issue has it's own sub-folder. 15 | - **Only match english volumes**: When Kapowarr is searching on ComicVine for a match to the file, only allow the match when it's an english release; it won't allow translations. 16 | - **Folder(s) to scan**: Allows you to supply a specific folder to scan, instead of all root folders. Supports glob patterns (e.g. `/comics-1/Star Wars*`). 17 | 18 | ### Importing 19 | 20 | When you are happy with the proposal, you have two options: 'Import' and 'Import and Rename'. Clicking 'Import' will make Kapowarr add all the volumes and set their volume folder to the folder that the file is in. Clicking 'Import and Rename' will make Kapowarr add all the volumes and move the files into the automatically generated volume folder, after which it will rename them. 21 | 22 | ### Implementation Details 23 | 24 | When 'Import' is used, the volume folder that is set is the 'deepest common folder'. This is the deepest folder that still contains all files that are matched to that volume. 25 | 26 | If the CV rate limit is reached halfway through the proposal, the unhandled files will have no match. Files that don't have a match linked to them will be ignored when importing, regardless of the state of the checkbox for that file. It's advised to wait a few minutes and then do another run. 27 | 28 | If you imported files but certain ones pop up again in the next run, see the [FAQ on this topic](../other_docs/faq.md#why-do-certain-files-pop-up-in-the-library-import-even-though-i-just-imported-them). 29 | -------------------------------------------------------------------------------- /docs/general_info/managing_volume.md: -------------------------------------------------------------------------------- 1 | # Volume Management 2 | 3 | ## Adding Volumes To Your Library 4 | 5 | There are two ways you can add volumes: manually or by importing. To add a volume to your library manually, follow the instructions below. To add a volume by importing files for it, see the [Library Import documentation](./features.md#library-import). 6 | 7 | To manually add a volume: 8 | 9 | 1. Make sure that you set a [Root Folder](../settings/mediamanagement.md#root-folders). 10 | 2. Make sure that you set your ComicVine API Key [in the settings](../settings/general.md#comic-vine-api-key). 11 | 3. In the web-UI, go to Volumes -> Add Volume. 12 | 4. Enter a search term (or the CV ID of the volume). 13 | 5. In the search results, click on the volume that you want to add. 14 | 6. Choose a root folder to put the volume folder in, change the volume folder if the generated one isn't desired (it's generated based on [the setting for it](../settings/mediamanagement.md#volume-folder-naming)) and choose if it should be monitored. 15 | 7. Click 'Add Volume' to add it to your library. Done! 16 | 17 | ## Managing Files 18 | 19 | Now that you have a volume in your library, Kapowarr/you can start managing it. 20 | 21 | When clicking on a volume in your library, you get taken to the volume page. It shows information about the volume and the issues that are in it. You can also click on an issue to get extra specific information about it. At the top, there is a tool bar with multiple options. 22 | 23 | ### Refresh & Scan 24 | 25 | The 'Refresh & Scan' button will update the metadata of the volume (= refresh) and scan for files (= scan). Under the metadata update falls data like the poster, title, release year and description but also the issue list and their descriptions, issue numbers and release dates. The file scanning will look in the volume folder for files and will try to match them to the issues. If Kapowarr is able to match them to an issue, that issue will be marked as downloaded. The criteria that the file has to meet in order to match to an issue can be found on the ['Matching' page](./matching.md). 26 | 27 | On the home page (a.k.a. library page/view), the button 'Update All' will trigger a Refresh & Scan for all volumes. The metadata of a volume is automatically updated every 24 hours by default, but will be forcibly updated if you trigger a Refresh & Scan manually. More information on the risks of doing this too often can be found on the ['Rate Limiting' page](../other_docs/rate_limiting.md#comicvine). 28 | 29 | ### Preview Rename 30 | 31 | Kapowarr has the ability to easily manage your files by (re)naming them all to one consistent format. You can change how Kapowarr should name files and folders [in the settings](../settings/mediamanagement.md#file-naming). The 'Preview Rename' button will show a list of the files for the volume and what Kapowarr will rename them to. Click the "Rename" button to finalise it. 32 | 33 | ### Preview Convert 34 | 35 | Another feature of Kapowarr is the ability to change the format of files. For example, Kapowarr can convert cbr files to cbz. To what format Kapowarr will change the files is set [in the settings](../settings/mediamanagement.md#format-preference). The 'Preview Convert' button will show a list of the files for the volume and what format Kapowarr will convert them to. Click the "Convert" button to finalise it. 36 | 37 | !!! info "Extracting Archives" 38 | If you download multiple issues in one go, they often come in an archive file (e.g. `Issue 1-10.zip`). Kapowarr can extract these archive files and put the contents directly in the folder. This functionality falls under 'Conversion'. See the ['Extract archives covering multiple issues' setting](../settings/mediamanagement.md#extract-archives-covering-multiple-issues) to enable this. 39 | 40 | ### Root folder and Volume folder 41 | 42 | Clicking on the 'Edit' button will show a screen where you can edit the two folders of the volume: the root folder and the volume folder. The root folder is the base folder that the volume folder lives in and the volume folder is the specific folder inside the root folder that is destined for the volume. You can change both, and Kapowarr will move all the files to the new location for you. 43 | 44 | ## Downloading 45 | 46 | This section covers the 'downloading' category. Implementation details on how Kapowarr downloads media can be found on the ['Downloading' page](./downloading.md). 47 | 48 | ### Monitoring 49 | 50 | If a volume is monitored, Kapowarr will try to automatically download media for it. If a volume is unmonitored, it won't. The monitored status does not exclude a volume from manual downloads, metadata updates ([Refresh & Scan](#refresh--scan)) or file scanning. You can also (un)monitor individual issues of a volume. 51 | 52 | ### Auto Search 53 | 54 | The button 'Search Monitored' will make Kapowarr try to download media for issues that aren't downloaded yet. This button only does something if the volume is monitored and at least one of it's monitored issues doesn't have a file yet. It will try to find a download for as many issues as possible, but it isn't guaranteed that it will always find a matching and working download. 55 | 56 | On the home page, the button 'Search All' will trigger a 'Search Monitored' for all monitored volumes. A search is done automatically every 24 hours by default, but you can also trigger it manually. 57 | 58 | ### Manual Search 59 | 60 | The button 'Manual Search' will show you a list of search results for the volume/issue. From these results, you can choose yourself which one Kapowarr will download. It is possible that the page does not contain any matching and working downloads. In that case, the download button will turn red and the page will be added to the blocklist. 61 | 62 | ### Download Queue and Post Processing 63 | 64 | When a download is added to the queue, you can see it on the Activity -> Queue page. When a download is complete, it will enter post-download processing (a.k.a. post-processing). Entirely depending on your configuration, the file could be renamed, converted to a different format and/or be extracted (if it's an archive file with multiple issues inside). It will always be moved from the [download folder](../settings/download.md#direct-download-temporary-folder) to it's final destination inside the volume folder. 65 | 66 | When you view the volume after the download, you'll see that the issue now has a check mark on the right, indicating that it has been downloaded. 67 | -------------------------------------------------------------------------------- /docs/general_info/matching.md: -------------------------------------------------------------------------------- 1 | This page covers how Kapowarr matches certain things. First a note on the Special Version, as it affects all matching. 2 | 3 | !!! tip "Matching using year" 4 | If it is an option to match using the year, both the release year of the volume and the release year of the issue is allowed. The year is also allowed to be off by one from the reference year. 5 | 6 | ## Special Version 7 | 8 | The matching criteria differ based on the type of volume. Kapowarr calls this the "Special Version" of the volume. A volume can be a "Normal Volume", "Trade Paper Back", "One Shot", "Hard Cover" or "Volume As Issue". Kapowarr tries it's best to automatically determine the type, but there are scenario's where it's wrong. You can override the Special Version when adding a volume, or by editing the volume. This setting is one of the first things you should check in case matching does not work for a volume. 9 | 10 | !!! info "What is a "Volume As Issue" volume?" 11 | The "Volume As Issue" Special Version is for volumes where each issue is named "Volume N", where N is a number. An example of such a volume is [Reign of X](https://comicvine.gamespot.com/reign-of-x/4050-137265/). Issue 1 is named "Volume 1", issue 2 is named "Volume 2", etc. 12 | 13 | If a specific string is required, most common variations are also supported. For example, if the string 'one-shot' is required, the variations 'one shot' and 'os' are also allowed. And upper case or lower case does not matter. 14 | 15 | ## Files to Issues 16 | 17 | This covers how Kapowarr matches files to issues of a volume. Information is extracted from the filename, folder and parent folder. 18 | 19 | ### Normal Volume 20 | 21 | Rules: 22 | 23 | 1. Has to mention issue number and should match. 24 | 2. Either year or volume number has to be mentioned and should match. 25 | 26 | Examples: 27 | 28 | 1. `Iron-Man Volume 2 Issue 3.cbr` 29 | 2. `Batman (1940) Vol. 2 #11-25.zip` 30 | 31 | ### 'Volume As Issue' Volume 32 | 33 | This is a volume where the issue titles are in the format 'Volume N'. 34 | 35 | Rules: 36 | 37 | 1. Volume number of file refers to issue number of volume or volume number of file refers to volume number of volume and issue number of file refers to issue number of volume. 38 | 2. Either year or volume number has to be mentioned and should match. 39 | 40 | Examples: 41 | 42 | 1. `Invincible Compendium (2011) Volume 2 - 3.cbr` 43 | 2. `Invincible Compendium (2011) Volume 1 Issue 2 - 3.cbr` 44 | 45 | ### TPB Volume 46 | 47 | Rules: 48 | 49 | 1. Is allowed to have 'TPB'. 50 | 2. Is not allowed to have issue number. 51 | 3. Either year or volume number has to be mentioned and should match. 52 | 53 | Examples: 54 | 55 | 1. `Silver Surfer Rebirth (2022) Volume 1 TPB.cbz` 56 | 2. `Silver Surfer Rebirth (2022) Volume 1.cbz` 57 | 3. `Silver Surfer Rebirth Volume 1.cbz` 58 | 4. `Silver Surfer Rebirth (2022).cbz` 59 | 60 | The following is _not_ allowed: 61 | 62 | 1. `Silver Surfer Rebirth Volume 1 Issue 001.cbz` 63 | 64 | ### One Shot Volume 65 | 66 | Rules: 67 | 68 | 1. Has to mention 'one-shot', issue number 1 or no issue number. 69 | 2. Either year or volume number has to be mentioned and should match. 70 | 71 | Examples: 72 | 73 | 1. `Elvira Mistress of the Dark Spring Special One Shot (2019) Volume 1.cbz` 74 | 2. `Elvira Mistress of the Dark Spring Special (2019).cbz` 75 | 3. `Elvira Mistress of the Dark Spring Special Volume 1.cbz` 76 | 4. `Elvira Mistress of the Dark Spring Special Volume 1 Issue 001.cbz` 77 | 78 | ### Hard Cover Volume 79 | 80 | Rules: 81 | 82 | 1. Has to mention 'hard-cover', issue number 1 or no issue number. 83 | 2. Either year or volume number has to be mentioned and should match. 84 | 85 | Examples: 86 | 87 | 1. `John Constantine, Hellblazer 30th Anniversary Celebration (2018) Hard-Cover.cbr` 88 | 2. `John Constantine, Hellblazer 30th Anniversary Celebration (2018).cbr` 89 | 3. `John Constantine, Hellblazer 30th Anniversary Celebration Volume 1.cbr` 90 | 4. `John Constantine, Hellblazer 30th Anniversary Celebration Volume 1 Issue 01.cbr` 91 | 92 | ## GetComics Search Results 93 | 94 | When searching for a GC release, Kapowarr determines if the page is a match for the volume or not. The release has to conform with the following rules to pass the filter: 95 | 96 | 1. Not be blocklisted. 97 | 2. Series title has to match. 98 | 3. If the volume number is given, it should match. 99 | 4. If the year is given, it should match. 100 | 5. If it is for a hard cover or one shot, it has to follow the first rule they have for files. 101 | 6. If it is for a TPB, it has to follow the first two rules it has for files. 102 | 7. If not a special version, the issue number should match to an issue in the volume. 103 | 104 | ## GetComics Groups 105 | 106 | When selecting links from a GC page for downloading, Kapowarr filters the groups so that no irrelevant files are downloaded. See the ['Downloading' page](./downloading.md) for more information. The download group has to conform with the following rules to pass the filter: 107 | 108 | 1. Series title has to match. 109 | 2. If the volume number is given, it should match. 110 | 3. If the year is given, it should match. 111 | 4. If it is for a hard cover or one shot, it has to follow the first rule they have for files. 112 | 5. If it is for a TPB, it has to follow the first two rules it has for files. 113 | 114 | ## Archive Extractions 115 | 116 | When extracting files from archives, the files are filtered and deleted if they are not for the volume. A file has to conform with the following rules to pass the filter: 117 | 118 | 1. Series title has to match. 119 | 2. Either year or volume number has to be mentioned and should match, or neither should be mentioned. 120 | -------------------------------------------------------------------------------- /docs/general_info/workings.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Here you can learn more about how to use Kapowarr, how features work and certain implementation details. 4 | 5 | [Managing A Volume](./managing_volume.md) 6 | More information on the basic usage of Kapowarr. 7 | 8 | [Downloading](./downloading.md) 9 | Detailed information on how Kapowarr downloads media. 10 | 11 | [Matching](./matching.md) 12 | Details on how Kapowarr matches files to issues, GC search results and other things. 13 | 14 | [Features](./features.md) 15 | Functionality and implementation details for certain features of Kapowarr. 16 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: The Official Kapowarr Documentation Hub! 3 | hide: 4 | - navigation 5 | --- 6 | # Kapowarr Documentation Hub 7 | 8 | __Kapowarr is a software to build and manage a comic book library, fitting in the *arr suite of software.__ 9 | 10 | Kapowarr allows you to build a digital library of comics. You can add volumes, map them to a folder and start managing! Download, rename, move and convert issues of the volume (including TPB's, One Shots, Hard Covers, and more). The whole process is automated and can be customised in the settings. 11 | 12 | ## Finding Documentation 13 | 14 | In the web-UI of Kapowarr, the documentation for that topic will be found at the same URL path. 15 | This means that for information about the 'File Naming' setting, (found at `{host}/settings/mediamanagement#file-naming`), the docs for that setting can be found at `https://casvt.github.io/Kapowarr/settings/mediamanagement#file-naming`. 16 | 17 | ## Quick Links 18 | 19 | ### Getting Started 20 | 21 | - [Installation and Updating](./installation/installation.md) 22 | - [Setup after installation](./installation/setup_after_installation.md) 23 | 24 | ### General Information 25 | 26 | - [Basic usage](./general_info/managing_volume.md) 27 | - [How features work and their implementation](./general_info/workings.md) 28 | - [Handling of the rate limits](./other_docs/rate_limiting.md) 29 | 30 | ### Support 31 | 32 | - [Explanation of settings](./settings/settings.md) 33 | - [Frequently Asked Questions (FAQ)](./other_docs/faq.md) 34 | - For support, a [Discord server](https://discord.gg/5gWtW3ekgZ) and [subreddit](https://www.reddit.com/r/kapowarr/) are available. 35 | - For issues or feature suggestions, see the [Reporting page](./other_docs/reporting.md). 36 | - For donations, go to [Ko-Fi](https://ko-fi.com/casvt). 37 | 38 | ### Beta Documentation 39 | 40 | _Documentation that only applies to the beta releases._ 41 | 42 | - [Beta Documentation](./beta/beta.md) 43 | -------------------------------------------------------------------------------- /docs/installation/installation.md: -------------------------------------------------------------------------------- 1 | Installing Kapowarr can be done via Docker or via a manual install. Docker requires less setup and has better support, but if your OS/system does not support Docker, you can also install Kapowarr directly on your OS via a manual install. 2 | 3 | !!! success "Recommended Installation" 4 | The recommended way to install Kapowarr is using Docker. 5 | 6 | For instructions on installing Kapowarr using Docker, see the [Docker installation instructions](./docker.md). For instructions on installing Kapowarr via a manual install, see the [manual installation instructions](./manual_install.md). 7 | 8 | After installing Kapowarr, there is some setup needed so that Kapowarr can start doing it's job, work optimally and act to your preferences. It's advised to visit the [Setup After Installation page](./setup_after_installation.md) after installation for more information. 9 | 10 | Updating an installation can also be found on the installation pages of the respective installation method. 11 | 12 | ## Quick Instructions 13 | 14 | If you already have experience with Docker and the *arr suite of apps, then below you can find some quick instructions to get Kapowarr up and running fast. If you need some more guidance, follow the full guide for [Docker](./docker.md) or [a manual install](./manual_install.md). 15 | 16 | You need to have a download folder and root folder created on the host. The database will be stored in a Docker volume. Replace the paths (`/path/to/...`) with their respective values. Add the mapped folder as your root folder in Kapowarr (`/comics-1`). See the [examples](./docker.md#example) for some extra help. 17 | 18 | === "Docker CLI" 19 | === "Linux" 20 | 21 | ```bash 22 | docker run -d \ 23 | --name kapowarr \ 24 | -v "kapowarr-db:/app/db" \ 25 | -v "/path/to/download_folder:/app/temp_downloads" \ 26 | -v "/path/to/root_folder:/comics-1" \ 27 | -p 5656:5656 \ 28 | mrcas/kapowarr:latest 29 | ``` 30 | 31 | === "MacOS" 32 | 33 | ```bash 34 | docker run -d \ 35 | --name kapowarr \ 36 | -v "kapowarr-db:/app/db" \ 37 | -v "/path/to/download_folder:/app/temp_downloads" \ 38 | -v "/path/to/root_folder:/comics-1" \ 39 | -p 5656:5656 \ 40 | mrcas/kapowarr:latest 41 | ``` 42 | 43 | === "Windows" 44 | 45 | ```powershell 46 | docker run -d --name kapowarr -v "kapowarr-db:/app/db" -v "DRIVE:\with\download_folder:/app/temp_downloads" -v "DRIVE:\with\root_folder:/comics-1" -p 5656:5656 mrcas/kapowarr:latest 47 | ``` 48 | 49 | === "Docker Compose" 50 | 51 | ```yml 52 | version: "3.3" 53 | services: 54 | kapowarr: 55 | container_name: kapowarr 56 | image: mrcas/kapowarr:latest 57 | volumes: 58 | - "kapowarr-db:/app/db" 59 | - "/path/to/download_folder:/app/temp_downloads" 60 | - "/path/to/root_folder:/comics-1" 61 | ports: 62 | - 5656:5656 63 | 64 | volumes: 65 | kapowarr-db: 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/installation/manual_install.md: -------------------------------------------------------------------------------- 1 | On this page, you can find instructions on how to manually install Kapowarr (directly on the host) and on how to update your manual installation. 2 | 3 | ## Installation 4 | 5 | !!! warning 6 | These instructions are still under construction. 7 | 8 | === "Windows" 9 | On Windows, there are a couple of extra steps involved. 10 | 11 | 1. [Download and install Python](https://www.python.org/downloads/). This is the framework Kapowarr runs on top of. 12 | _Make sure you select to add Python to PATH when prompted. This will make installing requirements much easier._ 13 | 2. Download (or clone) the [latest Kapowarr release](https://github.com/Casvt/Kapowarr/releases/latest). 14 | 3. Extract the zip file to a folder on your machine. 15 | We suggest something straightforward - `C:\services\Kapowarr` is what we'll use as an example. 16 | 4. Install the required python modules (found in `requirements.txt`). 17 | This can be achieved from a command prompt, by changing to the folder you've extracted Kapowarr to and running a python command. 18 | ```powershell 19 | cd C:\services\Kapowarr 20 | python -m pip install -r requirements.txt 21 | ``` 22 | 5. Run Kapowarr with the command `python C:\services\Kapowarr\kapowarr.py`. 23 | 6. Access Kapowarr with the IP of the host machine and port 5656. 24 | If it's the machine you're using, try [http://localhost:5656](http://localhost:5656) 25 | 26 | If you want Kapowarr to run in the background, without you having to start it each time your machine restarts, a tool called [nssm](https://nssm.cc/download) will allow you to configure Kapowarr to run as a system service. It is recommended that you set it up as above before doing this, as it will allow you to see any errors you may encounter on screen (instead of having nssm intercept them). 27 | 28 | === "Ubuntu" 29 | _Coming soon._ 30 | 31 | === "macOS" 32 | Use docker. 33 | Permissions on macOS (and GateKeeper) make this needlessly complex. 34 | 35 | ## Updating install 36 | 37 | Coming Soon. 38 | -------------------------------------------------------------------------------- /docs/installation/setup_after_installation.md: -------------------------------------------------------------------------------- 1 | After installing Kapowarr, you should have access to the web-ui. If not, check the [FAQ on this topic](../other_docs/faq.md#how-do-i-access-the-web-ui). Kapowarr needs some configuration in order for it to work properly. 2 | 3 | ## Port 4 | 5 | The first thing to do is decide if you want to leave Kapowarr on the default port of 5656. If you want to _keep_ the port, you can go to the next step. If you want to _change_ the port, see the ['Port Number' setting](../settings/general.md#port-number). 6 | 7 | ## Authentication 8 | 9 | If you want to put a password on your instance of Kapowarr, see the ['Login Password' setting](../settings/general.md#login-password). 10 | 11 | !!! warning "Exposing Kapowarr" 12 | If you are exposing your Kapowarr instance to the internet, we highly recommend setting a password. 13 | 14 | ## ComicVine API key 15 | 16 | Kapowarr uses [ComicVine](https://comicvine.gamespot.com/) as it's metadata source. To fetch the metadata from ComicVine, Kapowarr needs access to it's API, which requires an API key. 17 | 18 | See the ['Comic Vine API Key' setting](../settings/general.md#comic-vine-api-key) for how to get one. Once you've entered your key and hit 'Save', move on to Root Folders. 19 | 20 | ## Root folders 21 | 22 | Root folders are the base folders that Kapowarr works in. All media files are put in these folders. See the ['Root Folders' section of the settings](../settings/mediamanagement.md#root-folders) for more details. 23 | 24 | !!! info "You need at least one root folder" 25 | At least one root folder must be set before you are able to add any volumes to your library. 26 | 27 | !!! warning "Adding root folders on Docker" 28 | If you use Docker to run Kapowarr, then the root folder that you enter in the web-UI is the mapped folder, not the folder path on the host machine. That means that if you followed the [installation instructions](../installation/docker.md#launch-container), you would need to enter `/comics-1`, `/comics-2`, etc. as your root folder. This mistake is often made. 29 | 30 | ## Direct Download Temporary Folder 31 | 32 | This is only applicable to people _not_ using Docker. If you want to, you can change the folder that Kapowarr downloads files to using the ['Direct Download Temporary Folder' setting](../settings/download.md#direct-download-temporary-folder). 33 | 34 | ## Service preference 35 | 36 | The ['Service Preference' section of the settings](../settings/download.md#service-preference) dictate which download service Kapowarr should choose when multiple are available to use for a download. 37 | 38 | If you have an account with Mega, set that service as the priority and add a credential for it. The other services will then be used as a fallback option for if a link fails. 39 | 40 | For a full explanation, see . 41 | 42 | ## Credentials 43 | 44 | This only applies if you have an account with Mega (for now). Kapowarr can take advantage of the higher limits (download speed, daily size limit, etc.) that an account has to offer. Enter the credentials of the account at Settings -> Download Clients -> Mega. Afterwards, go to Settings -> Download -> Service Preference, and make sure to put Mega at the top. This will make Kapowarr prefer to use Mega when available. 45 | 46 | ## Building a library 47 | 48 | Now that you are ready, you can start [adding volumes to your library](../general_info/managing_volume.md#adding-volumes-to-your-library). If you have an existing library that you want to import into Kapowarr, use the [Library Import](../general_info/features.md#library-import) feature found at Volumes -> Library Import. 49 | -------------------------------------------------------------------------------- /docs/other_docs/api.md: -------------------------------------------------------------------------------- 1 | # API documentation 2 | 3 | ## Coming Soon 4 | -------------------------------------------------------------------------------- /docs/other_docs/rate_limiting.md: -------------------------------------------------------------------------------- 1 | # Rate limiting 2 | 3 | This page covers how Kapowarr handles the rate limits of the services it uses. 4 | 5 | ## ComicVine 6 | 7 | Hourly, Kapowarr finds the volumes that haven't had a metadata fetch for more than a day. It tries to fetch for as many volumes as possible. If it can't fetch for them all in one go, the ones that didn't get fetched, get preference the next hour. 8 | 9 | With this setup, all volumes (unless you have an absurdly big library) get updated every day and as little as possible requests are made. When we still surpass the limit, the volumes that need to be fetched the most (the ones that haven't been updated for the longest) get preference to ensure that they "keep up". Kapowarr can update at most 25.000 volumes and 25.000 issues per hour. 10 | 11 | ## Mega 12 | 13 | If a Mega download reaches the rate limit of the account mid-download (no way to calculate this beforehand), the download is canceled and all other Mega downloads in the download queue are removed. From that point on, Mega downloads are skipped until we can download from it again. Alternative services like MediaFire and GetComics are used instead of Mega while we wait for the limit to go down again. If you have a Mega account that offers higher limits, it's advised to add it at Settings -> Download -> [Credentials](../settings/download.md#credentials), so that Kapowarr can take advantage of it. 14 | -------------------------------------------------------------------------------- /docs/settings/download.md: -------------------------------------------------------------------------------- 1 | ## Download Location 2 | 3 | ### Direct Download Temporary Folder 4 | 5 | This is where the files being downloaded get written to before being processed and moved to the correct location. 6 | 7 | If you run Kapowarr using Docker, leave this set to the default value of `/app/temp_downloads` and instead change the value of `/path/to/download_folder` in the [Docker command](../installation/docker.md#launch-container). If you have a manual install, you can change this value to whatever you want. It is required to be outside your root folders. 8 | 9 | ### Empty Temporary Download Folder 10 | 11 | This isn't so much of a setting as it is a tool. It will delete all files from the download folder that aren't actively being downloaded. This can be handy if the application crashed while downloading, leading to half-downloaded 'ghost' files in the folder. 12 | 13 | ## Queue 14 | 15 | ### Failing Download Timeout 16 | 17 | If a download is stalled (no seeders, no servers, no metadata found, etc.) for a long time, you can be pretty confident that it's not going to work. Kapowarr can automatically delete a download when it's stalled for a set amount of minutes. So for example, if you set it to 60, then Kapowarr will delete downloads that have been stalled for more than 60 minutes. Make the field empty (or set it to 0) to disable this feature. 18 | 19 | ### Seeding Handling 20 | 21 | When a torrent has completed downloading, it will start to seed depending on the settings of the torrent client. The originally downloaded files need to be available in order to seed. But you might not want to wait for the torrent to complete seeding before you can read the downloaded comics. Kapowarr offers two solutions: 22 | 23 | 1. **Complete**: wait until the torrent has completed seeding and then move the files. You'll have to wait until the torrent has completed seeding before the comics are available. 24 | 2. **Copy**: make a copy of the downloaded files and post-process those (moving, renaming, converting, etc.). When the torrent finishes seeding, it's files are deleted. With this setup, your downloaded comics will be available immediately, but will temporarily take up twice as much space. 25 | 26 | ### Delete Completed Downloads 27 | 28 | Whether Kapowarr should delete external downloads from their client once they have completed. Otherwise leave them in the queue of the external download client as 'completed'. 29 | 30 | ## Service preference 31 | 32 | Kapowarr has the ability to download directly from the servers of GetComics, but also to download from services like MediaFire and Mega. When an issue is queried on [GetComics](https://getcomics.org/) and found to have multiple possible download sources, this defines which source takes priority. If the first download fails, Kapowarr will try the next service in order. 33 | 34 | If you have an account for one of these services (see [Credentials](./downloadclients.md#credentials) setting), you might want to put that one at the top, to make Kapowarr take advantage of the extra features that the account offers (extra bandwidth, higher rate limit, etc.). 35 | -------------------------------------------------------------------------------- /docs/settings/downloadclients.md: -------------------------------------------------------------------------------- 1 | ## Built-in Clients 2 | 3 | A list of the download clients Kapowarr has built-in. It uses these to download from multiple sources offered by GetComics. Clicking on one of them shows a window with more information and, if the client has support for it, an option to enter credentials (see below). 4 | 5 | ### Credentials 6 | 7 | If you have an account with Mega, Kapowarr has the ability to use this account. If you provide your login credentials for the service, Kapowarr will then take advantage of the extra features that your account has access to (higher speeds and limits, usually). You can enter the credentials by clicking on the client and filling in the form. 8 | 9 | ## Torrent Clients 10 | 11 | By adding at least one torrent client, Kapowarr is able to download torrents. 12 | 13 | !!! warning "Using localhost in combination with a Docker container" 14 | If the torrent client is hosted on the host OS, and Kapowarr is running inside a Docker container, then it is not possible to use `localhost` in the base URL of the torrent client. Instead, the IP address used by the host OS must be used. 15 | -------------------------------------------------------------------------------- /docs/settings/general.md: -------------------------------------------------------------------------------- 1 | ## Host 2 | 3 | This section defines how Kapowarr binds to a IP/port when starting up. Any setting here requires you to restart Kapowarr after saving it for it to apply. 4 | 5 | ### Bind Address 6 | 7 | This tells Kapowarr what IP to bind to. If you specify an IP that is _not_ on the machine running Kapowarr, you _will_ encounter errors. 8 | Using `0.0.0.0` will have Kapowarr bind to all interfaces it finds on the host machine. 9 | 10 | _Note: this setting is not applicable if you have Kapowarr deployed using Docker._ 11 | 12 | ### Port Number 13 | 14 | This tells Kapowarr what port to listen on. The default is `5656`, which would put the Kapowarr UI on `http://{HOST}:5656/`. 15 | 16 | If you have Kapowarr deployed using Docker, do not change this setting but instead follow the instructions below: 17 | 18 | === "Docker CLI" 19 | Alter the command to run the container by replacing `-p 5656:5656` with `-p {PORT}:5656`, where `{PORT}` is the desired port (e.g. `-p 8009:5656`). Run the container with the new version of the command (you will need to remove the old container if you had it running before). 20 | 21 | === "Docker Compose" 22 | Alter the file to run the container and replace `- 5656:5656` with `- {PORT}:5656`, where `{PORT}` is the desired port (e.g. `- 8009:5656`). Then re-run the container with the new version of the file. 23 | 24 | === "Docker Desktop" 25 | 1. Open `Containers` and locate the `kapowarr` container in the list. 26 | 2. Click the stop button on the right, then the delete button. 27 | 3. Follow the [instructions for launching the container](../installation/docker.md#launch-container), starting from step 3. At step 6, set the value to the desired port. For example, if you set it to `8009`, the web-UI will then be accessible via `http://{host}:8009/`. Continue following the rest of the steps. 28 | 29 | ### Base URL 30 | 31 | This is used for reverse proxy support - the default is empty. If you want to put Kapowarr behind a proxy (so you can access the web-UI via a nice URL), set a Base URL (it _must_ start with a `/` character). 32 | 33 | To get Kapowarr running on `http://example.com/kapowarr`, you would set your reverse proxy to forward the `/kapowarr` path to the IP and port of your Kapowarr instance, and set Base URL to `/kapowarr`. 34 | 35 | ## Security 36 | 37 | ### Login Password 38 | 39 | You might want to set a password to restrict access to the web-ui (and API). This is optional, but highly recommended if you are exposing Kapowarr to the internet. If you want to disable the password, set an empty value for the setting and save. 40 | 41 | ### API Key 42 | 43 | This is where Kapowarr defines the API key for any queries made to the [Kapowarr API](../other_docs/api.md). 44 | 45 | ## External Websites 46 | 47 | ### Comic Vine API Key 48 | 49 | Kapowarr uses ComicVine as its metadata source. To fetch the metadata from ComicVine, Kapowarr needs access to the API, which requires an API key. 50 | 51 | 1. Go to [the API page of ComicVine](https://comicvine.gamespot.com/api/). 52 | 2. If you don't have a free account at ComicVine already, sign up and once logged in, revisit the linked page. 53 | 3. You'll see your ComicVine API key, which is 40 characters long and contains the letters a-f and numbers 0-9 (e.g. `da39a3ee5e6b4b0d3255bfef95601890afd80709`). 54 | 4. Copy that API key and set it as the value in the web-UI. Don't forget to save. 55 | 56 | On the documentation page about [rate limiting](../other_docs/rate_limiting.md), information can be found about the handling of the ComicVine API rate limit. 57 | 58 | ### FlareSolverr Base URL 59 | 60 | Multiple services are protected by CloudFlare. This means that if Kapowarr makes too many requests too quickly, CloudFlare will block Kapowarr. [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) is a software that can bypass this block. Kapowarr can use FlareSolverr to make requests without getting blocked. If Kapowarr experiences a CloudFlare block and it doesn't have FlareSolverr setup, it will log this. Enter the base URL of your FlareSolverr instance if you want Kapowarr to make use of it. Supply the base URL without the API prefix (`/v1`). 61 | 62 | ## UI 63 | 64 | ### Theme 65 | 66 | The default theme is "Light". If you like dark mode, select "Dark". 67 | 68 | ## Logging 69 | 70 | ### Log Level 71 | 72 | The default log level is 'Info'. This means that only things that would appear in a console (or stdout) get logged. If you are troubleshooting or want to share logs, setting this to 'Debug' will make the system log what it's doing in much more detail. 73 | 74 | _Note that this should be set to 'Info' when not debugging, as Kapowarr logs so much in 'Debug' mode that it could slow down operation._ 75 | 76 | ### Download Logs 77 | 78 | By clicking the button, a text file will be downloaded containing all the latest logs. This file can be used in case a large amount of logs need to be shared (as it would be impractical to paste everything in a comment). 79 | -------------------------------------------------------------------------------- /docs/settings/settings.md: -------------------------------------------------------------------------------- 1 | You can find documentation for each setting here. It's also possible to find documentation for a setting by [using the same URL as in the web-UI](../index.md#finding-documentation). When you make changes in the settings, don't forget to click 'Save' at the top! 2 | -------------------------------------------------------------------------------- /frontend/static/css/add_volume.css: -------------------------------------------------------------------------------- 1 | main > div { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1.5rem; 5 | padding: 1.25rem; 6 | } 7 | 8 | /* */ 9 | /* SEARCH BAR */ 10 | /* */ 11 | .search-bar { 12 | height: 2.9rem; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | } 17 | 18 | #search-button, 19 | #search-cancel-button { 20 | height: 100%; 21 | aspect-ratio: 1/1; 22 | 23 | border: 1px solid var(--border-color); 24 | background-color: var(--foreground-color); 25 | } 26 | 27 | #search-button { 28 | border-radius: 4px 0px 0px 4px; 29 | border-right: 0px; 30 | } 31 | 32 | #search-cancel-button { 33 | border-radius: 0px 4px 4px 0px; 34 | border-left: 0px; 35 | } 36 | 37 | :where(#search-button, #search-cancel-button) > img { 38 | transform: scale(0.4); 39 | } 40 | 41 | #search-input { 42 | height: 100%; 43 | width: 100%; 44 | /* To make the "glow" go over the search-cancel-button */ 45 | z-index: 1; 46 | 47 | padding: .4rem 1rem; 48 | border: 1px solid var(--border-color); 49 | background-color: var(--foreground-color); 50 | color: var(--text-color); 51 | 52 | box-shadow: rgba(0, 0, 0, 0.075) 0px 1px 1px 0px inset; 53 | 54 | font-size: 1.1rem; 55 | } 56 | 57 | #search-input:focus { 58 | outline: 0; 59 | border-color: var(--accent-color); 60 | box-shadow: 61 | inset 0 1px 1px rgba(0, 0, 0, 0.075), 62 | 0 0 6px var(--accent-color); 63 | } 64 | 65 | /* */ 66 | /* SEARCH TEXTS */ 67 | /* */ 68 | #search-explain, 69 | #search-empty, 70 | #search-failed, 71 | #search-blocked, 72 | #search-loading { 73 | color: var(--text-color); 74 | text-align: center; 75 | font-size: 1.5rem; 76 | } 77 | 78 | #search-explain > *:not(:first-child) { 79 | font-size: 1.2rem; 80 | } 81 | 82 | /* */ 83 | /* FILTER BAR */ 84 | /* */ 85 | .filter-bar { 86 | display: flex; 87 | gap: 1rem; 88 | flex-wrap: wrap; 89 | } 90 | 91 | .filter-bar > * { 92 | flex-grow: 1; 93 | width: max(18%, 11rem); 94 | padding: 1rem; 95 | border-radius: 4px; 96 | background-color: var(--foreground-color); 97 | color: var(--text-color); 98 | } 99 | 100 | /* */ 101 | /* SEARCH RESULTS */ 102 | /* */ 103 | #search-results { 104 | display: flex; 105 | flex-direction: column; 106 | gap: 1rem; 107 | } 108 | 109 | .search-entry { 110 | display: flex; 111 | flex-direction: column; 112 | gap: 1rem; 113 | 114 | padding: 1.3rem; 115 | border-radius: 2px; 116 | background-color: var(--foreground-color); 117 | color: var(--text-color); 118 | 119 | text-align: left; 120 | 121 | transition: background-color 200ms linear; 122 | } 123 | 124 | .search-entry:hover { 125 | background-color: var(--hover-color); 126 | } 127 | 128 | .cover-info-container { 129 | width: 100%; 130 | display: flex; 131 | align-items: flex-start; 132 | gap: 1rem; 133 | } 134 | 135 | .cover-info-container > :first-child { 136 | width: clamp(6rem, 25%, 10rem); 137 | } 138 | 139 | .cover-info-container img { 140 | aspect-ratio: 2/3; 141 | border-radius: 2px; 142 | } 143 | 144 | .entry-info-container { 145 | width: 100%; 146 | 147 | display: flex; 148 | flex-direction: column; 149 | gap: .5rem; 150 | } 151 | 152 | .entry-info-container h2 { 153 | font-weight: 400; 154 | font-size: clamp(1.2rem, 4.5vw, 2rem); 155 | } 156 | 157 | .entry-info-container h2 span { 158 | opacity: .6; 159 | } 160 | 161 | .entry-info-container h2 img { 162 | margin-left: 1rem; 163 | width: 1.5rem; 164 | aspect-ratio: 1/1; 165 | } 166 | 167 | .entry-tags, 168 | .entry-aliases { 169 | display: flex; 170 | gap: .4rem; 171 | flex-wrap: wrap; 172 | } 173 | 174 | .entry-tags > *, 175 | .entry-aliases > * { 176 | background-color: var(--volume-info-background-color); 177 | color: var(--nav-color); 178 | padding: .15rem .35rem; 179 | text-decoration: none; 180 | border-radius: 2px; 181 | } 182 | 183 | .entry-spare-description { 184 | display: none; 185 | } 186 | 187 | /* */ 188 | /* ADD VOLUME WINDOW */ 189 | /* */ 190 | #add-window h2 { 191 | font-weight: 400; 192 | white-space: nowrap; 193 | overflow-x: hidden; 194 | text-overflow: ellipsis; 195 | } 196 | 197 | #add-cover { 198 | width: 10rem; 199 | height: 15rem; 200 | } 201 | 202 | .window .window-content { 203 | flex-direction: row; 204 | } 205 | 206 | #add-form { 207 | flex-grow: 1; 208 | width: 75%; 209 | } 210 | 211 | #add-form tbody { 212 | vertical-align: text-top; 213 | } 214 | 215 | #add-volume { 216 | background-color: var(--success-color); 217 | } 218 | 219 | @media (max-width: 1205px) { 220 | .filter-bar > * { 221 | min-width: 30%; 222 | } 223 | } 224 | 225 | @media (max-width: 720px) { 226 | #add-cover { 227 | display: none; 228 | } 229 | } 230 | 231 | @media (max-width: 600px) { 232 | .entry-description { 233 | display: none; 234 | } 235 | .entry-spare-description { 236 | display: block; 237 | } 238 | } 239 | 240 | @media (max-width: 440px) { 241 | .cover-info-container > :first-child { 242 | display: none; 243 | } 244 | main > div { 245 | padding: .75rem; 246 | } 247 | .search-entry { 248 | padding: 1rem; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /frontend/static/css/blocklist.css: -------------------------------------------------------------------------------- 1 | .main-window { 2 | padding: 1rem; 3 | } 4 | 5 | .table-container { 6 | overflow-x: auto; 7 | } 8 | 9 | table { 10 | width: 100%; 11 | min-width: 33.3rem; 12 | table-layout: fixed; 13 | 14 | color: var(--text-color); 15 | } 16 | 17 | th, td { 18 | padding: .5rem .9rem; 19 | } 20 | 21 | .link-column { 22 | overflow: auto; 23 | } 24 | 25 | .reason-column { 26 | width: 9rem; 27 | } 28 | 29 | .date-column { 30 | width: 11rem; 31 | } 32 | 33 | .option-column { 34 | width: 4.5rem; 35 | } 36 | 37 | .list-entry > * { 38 | border-top: 1px solid var(--border-color); 39 | } 40 | 41 | .delete-entry > img { 42 | width: 20px; 43 | } 44 | -------------------------------------------------------------------------------- /frontend/static/css/history.css: -------------------------------------------------------------------------------- 1 | .main-window { 2 | padding: 1rem; 3 | } 4 | 5 | .table-container { 6 | overflow-x: auto; 7 | } 8 | 9 | table { 10 | width: 100%; 11 | min-width: 30rem; 12 | 13 | color: var(--text-color); 14 | } 15 | 16 | tbody tr { 17 | transition: background-color 200ms ease-in-out; 18 | } 19 | 20 | tbody tr:hover { 21 | background-color: var(--hover-color); 22 | } 23 | 24 | th, td { 25 | padding: .5rem; 26 | } 27 | 28 | .history-entry > * { 29 | border-top: 1px solid var(--border-color); 30 | } 31 | 32 | .history-entry a:not([href]):hover { 33 | text-decoration: none; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/static/css/library_import.css: -------------------------------------------------------------------------------- 1 | main { 2 | padding-inline: 1rem; 3 | color: var(--text-color); 4 | } 5 | 6 | #start-window { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | gap: 1rem; 11 | 12 | padding-bottom: 1.5rem; 13 | } 14 | 15 | #start-window h2 { 16 | margin-top: min(3vw, 2rem); 17 | 18 | font-size: clamp(1rem, 5vw, 2rem); 19 | font-weight: 500; 20 | text-align: center; 21 | } 22 | 23 | #start-window > p, 24 | #start-window > ul { 25 | max-width: 50rem; 26 | color: var(--text-color); 27 | } 28 | 29 | #start-window > ul > li { 30 | margin-bottom: 1rem; 31 | } 32 | 33 | #start-window table td { 34 | padding: 1rem; 35 | padding-bottom: 0rem; 36 | width: 50%; 37 | } 38 | 39 | #start-window table td:first-child { 40 | text-align: right; 41 | } 42 | 43 | #start-window table :where(select, input) { 44 | min-width: 4rem; 45 | height: 2rem; 46 | 47 | border-radius: 4px; 48 | border: 2px solid var(--border-color); 49 | padding: .25rem .5rem; 50 | background-color: var(--foreground-color); 51 | color: var(--text-color); 52 | 53 | font-size: .9rem; 54 | 55 | transition: background-color 100ms linear; 56 | } 57 | 58 | #start-window table :where(select, input):hover { 59 | background-color: var(--hover-color); 60 | } 61 | 62 | #folder-filter-input { 63 | width: 100%; 64 | 65 | display: none; 66 | } 67 | 68 | #start-window table:has(#target-input option[value="false"]:checked) #folder-filter-input { 69 | display: block; 70 | } 71 | 72 | #folder-filter-error { 73 | margin-bottom: 1rem; 74 | } 75 | 76 | #run-import-button, 77 | .cancel-button, 78 | .action-container button, 79 | #no-cv-window a { 80 | min-width: 5rem; 81 | 82 | border: 2px solid var(--border-color); 83 | border-radius: 4px; 84 | padding: .4rem .5rem; 85 | color: var(--text-color); 86 | background-color: var(--foreground-color); 87 | 88 | font-size: 1rem; 89 | text-decoration: none; 90 | 91 | transition: background-color 100ms linear; 92 | } 93 | 94 | #run-import-button:hover, 95 | .cancel-button:hover, 96 | .action-container button:hover, 97 | #no-cv-window h2:hover { 98 | background-color: var(--hover-color); 99 | } 100 | 101 | #loading-window, 102 | #no-result-window, 103 | #no-cv-window { 104 | display: flex; 105 | flex-direction: column; 106 | justify-content: center; 107 | align-items: center; 108 | gap: 1.5rem; 109 | } 110 | 111 | #loading-window h2, 112 | #no-result-window h2, 113 | #no-cv-window h2 { 114 | font-size: clamp(1rem, 5vw, 2rem); 115 | font-weight: 500; 116 | text-align: center; 117 | } 118 | 119 | .action-container { 120 | display: flex; 121 | justify-content: center; 122 | align-items: center; 123 | gap: 1rem; 124 | flex-wrap: wrap; 125 | 126 | padding: 1rem; 127 | } 128 | 129 | .table-container { 130 | padding: 1rem; 131 | } 132 | 133 | .table-container > table { 134 | min-width: 30rem; 135 | width: 100%; 136 | } 137 | 138 | .table-container :where(td, th) { 139 | padding: .5rem 1rem; 140 | } 141 | 142 | .table-container th { 143 | padding-left: 1rem; 144 | } 145 | 146 | .proposal-list > tr { 147 | transition: background-color 400ms linear; 148 | } 149 | 150 | .proposal-list > tr:hover { 151 | background-color: var(--hover-color); 152 | } 153 | 154 | .proposal-list td { 155 | border-top: 1px solid var(--border-color); 156 | } 157 | 158 | .proposal-list td:first-child { 159 | width: 3rem; 160 | } 161 | 162 | .proposal-list td a { 163 | color: var(--accent-color); 164 | } 165 | 166 | .proposal-list button img { 167 | height: 1.5rem; 168 | } 169 | 170 | #cv-window .window-content { 171 | justify-content: flex-start; 172 | } 173 | 174 | .search-bar { 175 | width: 100%; 176 | height: 2.5rem; 177 | 178 | display: flex; 179 | justify-content: center; 180 | align-items: center; 181 | } 182 | 183 | .search-bar > input { 184 | height: 100%; 185 | width: min(100%, 20rem); 186 | 187 | padding: .5rem; 188 | border: 2px solid var(--border-color); 189 | border-radius: 4px 0px 0px 4px; 190 | background-color: var(--background-color); 191 | color: var(--text-color); 192 | } 193 | 194 | .search-bar > input:focus { 195 | outline: 0; 196 | border-color: var(--accent-color); 197 | box-shadow: 198 | inset 0 1px 1px rgba(0, 0, 0, 0.075), 199 | 0 0 6px var(--accent-color); 200 | } 201 | 202 | .search-bar > button { 203 | height: 100%; 204 | aspect-ratio: 1/1; 205 | 206 | padding: .6rem; 207 | border: 2px solid var(--border-color); 208 | border-left: none; 209 | border-radius: 0px 4px 4px 0px; 210 | } 211 | 212 | .search-results-container { 213 | padding: .5rem; 214 | overflow-x: auto; 215 | } 216 | 217 | .search-results-container table { 218 | min-width: 25rem; 219 | } 220 | 221 | .search-results-container th { 222 | padding-left: 1rem; 223 | } 224 | 225 | .search-results > tr { 226 | transition: background-color 400ms linear; 227 | } 228 | 229 | .search-results > tr:hover { 230 | background-color: var(--hover-color); 231 | } 232 | 233 | .search-results td { 234 | padding: .5rem 1rem; 235 | border-top: 1px solid var(--border-color); 236 | } 237 | 238 | .search-results td:last-child { 239 | min-width: 9rem; 240 | } 241 | 242 | .search-results td a { 243 | color: var(--accent-color); 244 | } 245 | 246 | .search-results button { 247 | border: 2px solid var(--border-color); 248 | border-radius: 4px; 249 | padding: .3rem; 250 | color: var(--text-color); 251 | background-color: var(--foreground-color); 252 | } 253 | -------------------------------------------------------------------------------- /frontend/static/css/login.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | gap: 8rem; 8 | 9 | background-color: var(--background-color); 10 | } 11 | 12 | #text-container { 13 | text-align: center; 14 | } 15 | 16 | #text-container h1 { 17 | margin-bottom: .5rem; 18 | 19 | color: var(--accent-color); 20 | 21 | font-size: min(20vw, 8rem); 22 | } 23 | 24 | #text-container p { 25 | color: var(--text-color); 26 | } 27 | 28 | #login-form { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | gap: 1rem; 33 | } 34 | 35 | #password-input { 36 | margin-bottom: .3rem; 37 | 38 | padding: .6rem; 39 | outline: 0; 40 | border: 1px solid transparent; 41 | border-radius: 4px; 42 | color: var(--dark-color); 43 | 44 | font-size: 1.3rem; 45 | 46 | transition: border .3s ease-in; 47 | } 48 | 49 | #password-input:focus { 50 | border: 1px solid var(--accent-color); 51 | } 52 | 53 | #password-input::placeholder { 54 | font-size: .6rem; 55 | text-align: right; 56 | transition: color .3s ease-out; 57 | } 58 | 59 | #password-input:focus::placeholder { 60 | color: transparent; 61 | } 62 | 63 | #login-form button { 64 | padding: .5rem 1.6rem; 65 | border-radius: 3px; 66 | color: var(--dark-color); 67 | background-color: var(--accent-color); 68 | 69 | font-size: 1.1rem; 70 | } -------------------------------------------------------------------------------- /frontend/static/css/page_not_found.css: -------------------------------------------------------------------------------- 1 | body { 2 | justify-content: center; 3 | background-color: var(--background-color); 4 | } 5 | 6 | h1 { 7 | color: var(--accent-color); 8 | text-align: center; 9 | font-size: min(12vw, 5rem); 10 | } -------------------------------------------------------------------------------- /frontend/static/css/queue.css: -------------------------------------------------------------------------------- 1 | main { 2 | position: relative; 3 | } 4 | 5 | #empty-window { 6 | position: absolute; 7 | inset: 0 0 0 0; 8 | background-color: var(--background-color); 9 | } 10 | 11 | main:has(#queue tr) #empty-window, 12 | main:not(:has(#queue tr)) .tool-bar-container { 13 | display: none; 14 | } 15 | 16 | table { 17 | width: 100%; 18 | min-width: 50rem; 19 | padding: 1rem; 20 | color: var(--text-color); 21 | } 22 | 23 | th, td { 24 | padding: .5rem; 25 | } 26 | 27 | .queue-entry > * { 28 | border-top: 1px solid var(--border-color); 29 | } 30 | 31 | .queue-entry:nth-child(1 of .queue-entry:not([data-status="downloading"])) .move-up-dl, 32 | .queue-entry:nth-last-child(1 of .queue-entry:not([data-status="downloading"])) .move-down-dl, 33 | .queue-entry[data-status="downloading"] :where(.move-up-dl, .move-down-dl) { 34 | display: none; 35 | } 36 | 37 | .queue-entry:nth-child(1 of .queue-entry:not([data-status="downloading"])) .move-down-dl { 38 | margin-left: calc(20px + .75rem); 39 | } 40 | 41 | .queue-entry:nth-last-child(1 of .queue-entry:not([data-status="downloading"])) .move-up-dl { 42 | margin-right: calc(20px + 1.25rem); 43 | } 44 | 45 | .queue-entry[data-status="downloading"] .remove-dl, 46 | .queue-entry:nth-child(1 of .queue-entry:not([data-status="downloading"])):nth-last-child(1 of .queue-entry:not([data-status="downloading"])) .remove-dl { 47 | margin-left: calc(20px + 2.75rem); 48 | } 49 | 50 | .status-column { 51 | width: clamp(7.5rem, 11vw, 10rem); 52 | } 53 | 54 | .number-column { 55 | width: 6.5rem; 56 | } 57 | 58 | .option-column { 59 | width: 9rem; 60 | } 61 | 62 | .option-column button:not(:last-child) { 63 | margin-right: .5rem; 64 | } 65 | 66 | .option-column button img { 67 | width: 20px; 68 | } 69 | 70 | @media (min-width: 720px) { 71 | main { 72 | width: calc(100vw - var(--nav-width)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/static/css/status.css: -------------------------------------------------------------------------------- 1 | main > div { 2 | padding: 1rem; 3 | color: var(--text-color); 4 | } 5 | 6 | h2 { 7 | margin-bottom: 1rem; 8 | 9 | display: flex; 10 | gap: 1rem; 11 | align-items: center; 12 | 13 | border-bottom: 1px solid var(--border-color); 14 | 15 | font-size: 2rem; 16 | font-weight: 500; 17 | } 18 | 19 | section:not(:first-of-type) h2 { 20 | margin-top: 1rem; 21 | } 22 | 23 | .container { 24 | overflow-x: auto; 25 | color: var(--text-color); 26 | } 27 | 28 | #copy-about { 29 | width: 4rem; 30 | 31 | padding: .5rem; 32 | border-radius: 4px; 33 | color: var(--nav-background-color); 34 | background-color: var(--accent-color); 35 | 36 | font-weight: 700; 37 | } 38 | 39 | #about-table { 40 | width: 100%; 41 | min-width: 31rem; 42 | } 43 | 44 | #about-table :where(th, td) { 45 | padding: .3rem .5rem; 46 | } 47 | 48 | #about-table th { 49 | width: 10rem; 50 | text-align: right; 51 | font-weight: 400; 52 | } 53 | 54 | #about-table td { 55 | margin-left: 1rem; 56 | } 57 | 58 | .link-list { 59 | display: flex; 60 | flex-wrap: wrap; 61 | gap: 1rem; 62 | } 63 | 64 | .link-list > :where(a, button) { 65 | display: flex; 66 | justify-content: center; 67 | align-items: center; 68 | gap: 1rem; 69 | 70 | border-radius: 4px; 71 | padding: 0.7rem 1rem; 72 | background-color: var(--accent-color); 73 | color: var(--nav-background-color); 74 | } 75 | 76 | .link-list > a > img { 77 | width: 2.5rem; 78 | } 79 | 80 | @media (max-width: 720px) { 81 | .link-list > :where(a, button) { 82 | flex-grow: 1; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /frontend/static/css/tasks.css: -------------------------------------------------------------------------------- 1 | .main-window { 2 | color: var(--text-color); 3 | padding: 1rem; 4 | } 5 | 6 | .main-window h2 { 7 | border-bottom: 2px solid var(--border-color); 8 | margin-bottom: 1rem; 9 | } 10 | 11 | .main-window h2:not(:first-child) { 12 | margin-top: 1rem; 13 | } 14 | 15 | .task-interval-container { 16 | overflow-x: auto; 17 | } 18 | 19 | .task-interval-table { 20 | min-width: 33.2rem; 21 | table-layout: fixed; 22 | } 23 | 24 | .task-interval-table tr > :not(:first-child) { 25 | width: 8.5rem; 26 | } 27 | 28 | .task-interval-table tr > td { 29 | border-top: 1px solid var(--border-color); 30 | } 31 | 32 | table { 33 | width: 100%; 34 | } 35 | 36 | th, td { 37 | padding: .5rem; 38 | } 39 | 40 | .history-entry > * { 41 | border-top: 1px solid var(--border-color); 42 | } 43 | -------------------------------------------------------------------------------- /frontend/static/css/window.css: -------------------------------------------------------------------------------- 1 | .window:not([show-window]) { 2 | display: none; 3 | } 4 | 5 | .window[show-window] { 6 | position: absolute; 7 | inset: 0 0 0 0; 8 | background-color: rgba(0, 0, 0, 0.6); 9 | z-index: 3; 10 | } 11 | 12 | .window > section:not([show-window]) { 13 | display: none; 14 | } 15 | 16 | .window > section[show-window] { 17 | position: absolute; 18 | width: clamp(640px, 65%, 775px); 19 | max-height: 90%; 20 | left: 50%; 21 | top: 50%; 22 | transform: translate(-50%, -50%); 23 | overflow: auto; 24 | display: flex; 25 | background-color: var(--background-color); 26 | color: var(--text-color); 27 | } 28 | 29 | .window > section[show-window].wide-window { 30 | width: clamp(720px, 85%, 1100px); 31 | } 32 | 33 | .window > section:not(.loading-window) { 34 | flex-direction: column; 35 | } 36 | 37 | .window > section.loading-window { 38 | min-height: 50%; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | 43 | .window > section > * { 44 | padding: clamp(.5rem, 3vw, 1.2rem) clamp(.5rem, 4.5vw, 1.4rem); 45 | } 46 | 47 | .window-header, 48 | .window-footer { 49 | min-height: 4.5rem; 50 | display: flex; 51 | align-items: center; 52 | font-size: .8rem; 53 | gap: 1rem; 54 | } 55 | 56 | /* Header */ 57 | .window-header { 58 | justify-content: space-between; 59 | border-bottom: 2px solid var(--border-color); 60 | } 61 | 62 | .window-header > button:last-of-type { 63 | height: 1rem; 64 | aspect-ratio: 1/1; 65 | padding: 0; 66 | background-color: transparent; 67 | } 68 | 69 | .window-header > button:last-of-type > img { 70 | height: 100%; 71 | } 72 | 73 | /* Main window */ 74 | .window-content { 75 | min-height: 15rem; 76 | display: flex; 77 | flex-direction: column; 78 | gap: 1rem; 79 | justify-content: center; 80 | overflow: auto; 81 | } 82 | 83 | .window-content form { 84 | align-self: center; 85 | } 86 | 87 | .window-content table { 88 | width: 100%; 89 | } 90 | 91 | .window-content tr > * { 92 | padding: .5rem; 93 | } 94 | 95 | .window-content th { 96 | font-size: .8rem; 97 | } 98 | 99 | .window-content table th :where(input) { 100 | width: min-content; 101 | } 102 | 103 | .window-content table :where(select, input) { 104 | width: 90%; 105 | padding: .5rem; 106 | border: 2px solid var(--border-color); 107 | border-radius: 2px; 108 | background-color: var(--background-color); 109 | color: var(--text-color); 110 | } 111 | 112 | .window-content td > p { 113 | margin-top: .2rem; 114 | margin-left: .1rem; 115 | font-size: .8rem; 116 | } 117 | 118 | /* Footer */ 119 | .window-footer { 120 | justify-content: flex-end; 121 | border-top: 2px solid var(--border-color); 122 | } 123 | 124 | .window-footer button { 125 | padding: .5rem 1rem; 126 | border-radius: 2px; 127 | color: var(--light-color); 128 | } 129 | 130 | .cancel-window { 131 | background-color: var(--error-color); 132 | } 133 | 134 | .window-footer button[type="submit"] { 135 | background-color: var(--success-color); 136 | } 137 | 138 | @media (max-width: 850px) { 139 | .window > section[show-window].wide-window { 140 | width: 90%; 141 | height: 90%; 142 | max-height: unset; 143 | } 144 | .window > section[show-window].wide-window .window-content { 145 | flex-grow: 1; 146 | } 147 | } 148 | 149 | @media (max-width: 720px) { 150 | .window > section[show-window] { 151 | width: 90%; 152 | height: 90%; 153 | max-height: unset; 154 | } 155 | .window > section[show-window] .window-content { 156 | flex-grow: 1; 157 | } 158 | .window > section[show-window].wide-window { 159 | width: 100%; 160 | height: 100%; 161 | } 162 | } 163 | 164 | @media (max-width: 600px) { 165 | .window > section[show-window] { 166 | width: 100%; 167 | height: 100%; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /frontend/static/img/arrow_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/arrow_up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/blocklist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/img/cancel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/img/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/img/check_circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/convert.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/static/img/discord.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/frontend/static/img/discord.ico -------------------------------------------------------------------------------- /frontend/static/img/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/static/img/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/files.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/getcomics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/frontend/static/img/getcomics.png -------------------------------------------------------------------------------- /frontend/static/img/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/img/ko-fi.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/frontend/static/img/ko-fi.webp -------------------------------------------------------------------------------- /frontend/static/img/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/static/img/manual_search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/img/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/static/img/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/static/img/rename.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/img/save.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/static/img/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/img/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/static/js/auth.js: -------------------------------------------------------------------------------- 1 | async function usingApiKey(redirect=true) { 2 | const key_data = JSON.parse(localStorage.getItem('kapowarr')); 3 | 4 | if (key_data.api_key === null 5 | || (key_data.last_login < (Date.now() / 1000 - 86400))) { 6 | 7 | return fetch(`${url_base}/api/auth`, { 8 | 'method': 'POST', 9 | 'headers': {'Content-Type': 'application/json'}, 10 | 'body': '{}' 11 | }) 12 | .then(response => { 13 | if (!response.ok) return Promise.reject(response.status); 14 | return response.json(); 15 | }) 16 | .then(json => { 17 | key_data.api_key = json.result.api_key; 18 | key_data.last_login = Date.now() / 1000; 19 | localStorage.setItem('kapowarr', JSON.stringify(key_data)); 20 | return json.result.api_key; 21 | }) 22 | .catch(e => { 23 | if (e === 401) { 24 | if (redirect) window.location.href = `${url_base}/login?redirect=${window.location.pathname}`; 25 | else return null; 26 | } else { 27 | console.log(e); 28 | return null; 29 | }; 30 | }) 31 | 32 | } else { 33 | return key_data.api_key; 34 | }; 35 | }; 36 | 37 | -------------------------------------------------------------------------------- /frontend/static/js/blocklist.js: -------------------------------------------------------------------------------- 1 | const BlockEls = { 2 | table: document.querySelector('#blocklist'), 3 | page_turner: { 4 | container: document.querySelector('.page-turner'), 5 | previous: document.querySelector('#previous-page'), 6 | next: document.querySelector('#next-page'), 7 | number: document.querySelector('#page-number') 8 | }, 9 | buttons: { 10 | refresh: document.querySelector('#refresh-button'), 11 | clear: document.querySelector('#clear-button') 12 | }, 13 | entry: document.querySelector('.pre-build-els .list-entry') 14 | }; 15 | 16 | var offset = 0; 17 | 18 | function fillList(api_key) { 19 | fetchAPI('/blocklist', api_key, {offset: offset}) 20 | .then(json => { 21 | BlockEls.table.innerHTML = ''; 22 | json.result.forEach(obj => { 23 | const entry = BlockEls.entry.cloneNode(true); 24 | 25 | const link = entry.querySelector('a'); 26 | if (obj.download_link === null) { 27 | // GC page blocked 28 | link.innerText = obj.web_title || obj.web_link; 29 | link.href = obj.web_link; 30 | 31 | } else { 32 | // Download link blocked 33 | if (obj.web_title !== null) { 34 | link.innerText = `${obj.web_title} - ${obj.web_sub_title}`; 35 | if (obj.source !== null) 36 | link.innerText += ` - ${obj.source}`; 37 | } else 38 | link.innerText = obj.download_link; 39 | 40 | link.href = obj.download_link; 41 | }; 42 | 43 | entry.querySelector('.reason-column').innerText = obj.reason; 44 | 45 | var d = new Date(obj.added_at * 1000); 46 | var formatted_date = 47 | d.toLocaleString('en-CA').slice(0,10) 48 | + ' ' 49 | + d.toTimeString().slice(0,5); 50 | entry.querySelector('.date-column').innerText = formatted_date; 51 | 52 | entry.querySelector('button').onclick = e => deleteEntry(obj.id, api_key); 53 | 54 | BlockEls.table.appendChild(entry); 55 | }); 56 | }); 57 | }; 58 | 59 | function deleteEntry(id, api_key) { 60 | sendAPI('DELETE', `/blocklist/${id}`, api_key) 61 | .then(response => fillList(api_key)); 62 | }; 63 | 64 | function clearList(api_key) { 65 | sendAPI('DELETE', '/blocklist', api_key) 66 | offset = 0; 67 | BlockEls.page_turner.number.innerText = 'Page 1'; 68 | BlockEls.table.innerHTML = ''; 69 | }; 70 | 71 | function reduceOffset(api_key) { 72 | if (offset === 0) return; 73 | offset--; 74 | BlockEls.page_turner.number.innerText = `Page ${offset + 1}`; 75 | fillList(api_key); 76 | }; 77 | 78 | function increaseOffset(api_key) { 79 | if (BlockEls.table.innerHTML === '') return; 80 | offset++; 81 | BlockEls.page_turner.number.innerText = `Page ${offset + 1}`; 82 | fillList(api_key); 83 | }; 84 | 85 | // code run on load 86 | usingApiKey() 87 | .then(api_key => { 88 | fillList(api_key); 89 | BlockEls.buttons.clear.onclick = e => clearList(api_key); 90 | BlockEls.buttons.refresh.onclick = e => fillList(api_key); 91 | BlockEls.page_turner.previous.onclick = e => reduceOffset(api_key); 92 | BlockEls.page_turner.next.onclick = e => increaseOffset(api_key); 93 | }); 94 | -------------------------------------------------------------------------------- /frontend/static/js/history.js: -------------------------------------------------------------------------------- 1 | const HistoryEls = { 2 | table: document.querySelector('#history'), 3 | page_turner: { 4 | container: document.querySelector('.page-turner'), 5 | previous: document.querySelector('#previous-page'), 6 | next: document.querySelector('#next-page'), 7 | number: document.querySelector('#page-number') 8 | }, 9 | buttons: { 10 | refresh: document.querySelector('#refresh-button'), 11 | clear: document.querySelector('#clear-button') 12 | }, 13 | entry: document.querySelector('.pre-build-els .history-entry') 14 | }; 15 | 16 | var offset = 0; 17 | 18 | function fillHistory(api_key) { 19 | fetchAPI('/activity/history', api_key, {offset: offset}) 20 | .then(json => { 21 | HistoryEls.table.innerHTML = ''; 22 | json.result.forEach(obj => { 23 | const entry = HistoryEls.entry.cloneNode(true); 24 | 25 | const title = entry.querySelector('a'); 26 | title.href = obj.web_link; 27 | title.innerText = obj.web_title; 28 | title.title = obj.web_title; 29 | if (obj.web_sub_title !== null) 30 | title.title += `\n\n${obj.web_sub_title}`; 31 | 32 | if (obj.file_title !== null) { 33 | const vol_link = entry.querySelector('td:nth-child(2) a') 34 | vol_link.innerText = obj.file_title; 35 | if (obj.volume_id !== null) 36 | vol_link.href = `${url_base}/volumes/${obj.volume_id}`; 37 | }; 38 | 39 | if (obj.source !== null) 40 | entry.querySelector('td:nth-child(3)').innerText = obj.source; 41 | 42 | let d = new Date(obj.downloaded_at * 1000); 43 | let formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5); 44 | entry.querySelector('td:last-child').innerText = formatted_date; 45 | 46 | HistoryEls.table.appendChild(entry); 47 | }); 48 | }); 49 | }; 50 | 51 | function clearHistory(api_key) { 52 | sendAPI('DELETE', '/activity/history', api_key) 53 | offset = 0; 54 | HistoryEls.page_turner.number.innerText = 'Page 1'; 55 | HistoryEls.table.innerHTML = ''; 56 | }; 57 | 58 | function reduceOffset(api_key) { 59 | if (offset === 0) return; 60 | offset--; 61 | HistoryEls.page_turner.number.innerText = `Page ${offset + 1}`; 62 | fillHistory(api_key); 63 | }; 64 | 65 | function increaseOffset(api_key) { 66 | if (HistoryEls.table.innerHTML === '') return; 67 | offset++; 68 | HistoryEls.page_turner.number.innerText = `Page ${offset + 1}`; 69 | fillHistory(api_key); 70 | }; 71 | 72 | // code run on load 73 | usingApiKey() 74 | .then(api_key => { 75 | fillHistory(api_key); 76 | HistoryEls.buttons.refresh.onclick = e => fillHistory(api_key); 77 | HistoryEls.buttons.clear.onclick = e => clearHistory(api_key); 78 | HistoryEls.page_turner.previous.onclick = e => reduceOffset(api_key); 79 | HistoryEls.page_turner.next.onclick = e => increaseOffset(api_key); 80 | }); 81 | -------------------------------------------------------------------------------- /frontend/static/js/login.js: -------------------------------------------------------------------------------- 1 | function redirect() { 2 | parameters = new URLSearchParams(window.location.search); 3 | redirect_value = parameters.get('redirect') || `${url_base}/`; 4 | window.location.href = redirect_value; 5 | }; 6 | 7 | function registerLogin(api_key) { 8 | const data = JSON.parse(localStorage.getItem('kapowarr')); 9 | data.api_key = api_key; 10 | data.last_login = Date.now(); 11 | localStorage.setItem('kapowarr', JSON.stringify(data)); 12 | redirect(); 13 | }; 14 | 15 | function login() { 16 | const error = document.querySelector('#error-message'); 17 | error.classList.add('hidden'); 18 | 19 | const password_input = document.querySelector('#password-input'); 20 | const data = { 21 | 'password': password_input.value 22 | }; 23 | fetch(`${url_base}/api/auth`, { 24 | 'method': 'POST', 25 | 'headers': {'Content-Type': 'application/json'}, 26 | 'body': JSON.stringify(data) 27 | }) 28 | .then(response => { 29 | if (!response.ok) return Promise.reject(response.status); 30 | return response.json(); 31 | }) 32 | .then(json => registerLogin(json.result.api_key)) 33 | .catch(e => { 34 | // Login failed 35 | if (e === 401) { 36 | error.classList.remove('hidden'); 37 | } else { 38 | console.log(e); 39 | }; 40 | }); 41 | }; 42 | 43 | // code run on load 44 | 45 | const url_base = document.querySelector('#url_base').dataset.value; 46 | 47 | usingApiKey(false) 48 | .then(api_key => { 49 | if (api_key) redirect(); 50 | }) 51 | 52 | if (JSON.parse(localStorage.getItem('kapowarr') || {'theme': 'light'})['theme'] === 'dark') 53 | document.querySelector(':root').classList.add('dark-mode'); 54 | 55 | document.querySelector('#login-form').action = 'javascript:login();'; 56 | -------------------------------------------------------------------------------- /frontend/static/js/queue.js: -------------------------------------------------------------------------------- 1 | const QEls = { 2 | queue: document.querySelector('#queue'), 3 | queue_entry: document.querySelector('.pre-build-els .queue-entry'), 4 | tool_bar: { 5 | remove_all: document.querySelector('#removeall-button') 6 | } 7 | }; 8 | 9 | // 10 | // Filling data 11 | // 12 | function addQueueEntry(api_key, obj) { 13 | const entry = QEls.queue_entry.cloneNode(true); 14 | entry.dataset.id = obj.id; 15 | QEls.queue.appendChild(entry); 16 | 17 | const title = entry.querySelector('a:first-of-type'); 18 | title.innerText = obj.title; 19 | title.href = `${url_base}/volumes/${obj.volume_id}`; 20 | 21 | const source = entry.querySelector('td:nth-child(3) a') 22 | source.innerText = 23 | obj.source_name.charAt(0).toUpperCase() + obj.source_name.slice(1); 24 | source.href = obj.web_link; 25 | source.title = `Page Title:\n${obj.web_title}`; 26 | if (obj.web_sub_title !== null) 27 | source.title += `\n\nSub Section:\n${obj.web_sub_title}`; 28 | 29 | const index = [...QEls.queue.children].indexOf(entry); 30 | entry.querySelector('.move-up-dl').onclick = e => moveEntry( 31 | obj.id, index - 1, api_key 32 | ); 33 | entry.querySelector('.move-down-dl').onclick = e => moveEntry( 34 | obj.id, index + 1, api_key 35 | ); 36 | entry.querySelector('.remove-dl').onclick = e => deleteEntry( 37 | obj.id, api_key 38 | ); 39 | entry.querySelector('.blocklist-dl').onclick = e => deleteEntry( 40 | obj.id, 41 | api_key, 42 | blocklist=true 43 | ); 44 | 45 | updateQueueEntry(obj); 46 | }; 47 | 48 | function updateQueueEntry(obj) { 49 | const tr = document.querySelector(`#queue > tr[data-id="${obj.id}"]`); 50 | tr.dataset.status = obj.status; 51 | tr.querySelector('td:nth-child(1)').innerText = 52 | obj.status.charAt(0).toUpperCase() + obj.status.slice(1); 53 | tr.querySelector('td:nth-child(4)').innerText = 54 | convertSize(obj.size); 55 | tr.querySelector('td:nth-child(5)').innerText = 56 | twoDigits(Math.round(obj.speed / 100000) / 10) + 'MB/s'; 57 | tr.querySelector('td:nth-child(6)').innerText = 58 | obj.size === -1 59 | ? convertSize(obj.progress) 60 | : twoDigits(Math.round(obj.progress * 10) / 10) + '%'; 61 | }; 62 | 63 | function removeQueueEntry(id) { 64 | document.querySelector(`#queue > tr[data-id="${id}"]`).remove(); 65 | }; 66 | 67 | function fillQueue(api_key) { 68 | fetchAPI('/activity/queue', api_key) 69 | .then(json => { 70 | QEls.queue.innerHTML = ''; 71 | json.result.forEach(obj => addQueueEntry(api_key, obj)); 72 | }) 73 | }; 74 | 75 | // 76 | // Actions 77 | // 78 | function deleteAll(api_key) { 79 | sendAPI('DELETE', '/activity/queue', api_key); 80 | }; 81 | 82 | function moveEntry(id, index, api_key) { 83 | sendAPI('PUT', `/activity/queue/${id}`, api_key, { 84 | index: index 85 | }, {}) 86 | .then(response => { 87 | if (!response.ok) 88 | return; 89 | 90 | fillQueue(api_key); 91 | }); 92 | } 93 | 94 | function deleteEntry(id, api_key, blocklist=false) { 95 | sendAPI('DELETE', `/activity/queue/${id}`, api_key, {}, { 96 | blocklist: blocklist 97 | }); 98 | }; 99 | 100 | // code run on load 101 | 102 | usingApiKey() 103 | .then(api_key => { 104 | fillQueue(api_key); 105 | socket.on('queue_added', data => addQueueEntry(api_key, data)); 106 | socket.on('queue_status', updateQueueEntry); 107 | socket.on('queue_ended', data => removeQueueEntry(data.id)); 108 | QEls.tool_bar.remove_all.onclick = e => deleteAll(api_key); 109 | }); 110 | -------------------------------------------------------------------------------- /frontend/static/js/settings_download.js: -------------------------------------------------------------------------------- 1 | function fillSettings(api_key) { 2 | fetchAPI('/settings', api_key) 3 | .then(json => { 4 | document.querySelector('#download-folder-input').value = json.result.download_folder; 5 | document.querySelector('#concurrent-direct-downloads-input').value = json.result.concurrent_direct_downloads; 6 | document.querySelector('#download-timeout-input').value = ((json.result.failing_download_timeout || 0) / 60) || ''; 7 | document.querySelector('#seeding-handling-input').value = json.result.seeding_handling; 8 | document.querySelector('#delete-downloads-input').checked = json.result.delete_completed_downloads; 9 | fillPref(json.result.service_preference); 10 | }); 11 | }; 12 | 13 | function saveSettings(api_key) { 14 | document.querySelector("#save-button p").innerText = 'Saving'; 15 | document.querySelector('#download-folder-input').classList.remove('error-input'); 16 | const data = { 17 | 'download_folder': document.querySelector('#download-folder-input').value, 18 | 'concurrent_direct_downloads': parseInt(document.querySelector('#concurrent-direct-downloads-input').value), 19 | 'failing_download_timeout': parseInt(document.querySelector('#download-timeout-input').value || 0) * 60, 20 | 'seeding_handling': document.querySelector('#seeding-handling-input').value, 21 | 'delete_completed_downloads': document.querySelector('#delete-downloads-input').checked, 22 | 'service_preference': [...document.querySelectorAll('#pref-table select')].map(e => e.value) 23 | }; 24 | sendAPI('PUT', '/settings', api_key, {}, data) 25 | .then(response => 26 | document.querySelector("#save-button p").innerText = 'Saved' 27 | ) 28 | .catch(e => { 29 | document.querySelector("#save-button p").innerText = 'Failed'; 30 | e.json().then(e => { 31 | if ( 32 | e.error === "InvalidSettingValue" 33 | && e.result.key === "download_folder" 34 | || 35 | e.error === "FolderNotFound" 36 | ) 37 | document.querySelector('#download-folder-input').classList.add('error-input'); 38 | 39 | else 40 | console.log(e); 41 | }); 42 | }); 43 | }; 44 | 45 | // 46 | // Empty download folder 47 | // 48 | function emptyFolder(api_key) { 49 | sendAPI('DELETE', '/activity/folder', api_key) 50 | .then(response => { 51 | document.querySelector('#empty-download-folder').innerText = 'Done'; 52 | }); 53 | }; 54 | 55 | // 56 | // Service preference 57 | // 58 | function fillPref(pref) { 59 | const selects = document.querySelectorAll('#pref-table select'); 60 | for (let i = 0; i < pref.length; i++) { 61 | const service = pref[i]; 62 | const select = selects[i]; 63 | select.onchange = updatePrefOrder; 64 | pref.forEach(option => { 65 | const entry = document.createElement('option'); 66 | entry.value = option; 67 | entry.innerText = option.charAt(0).toUpperCase() + option.slice(1); 68 | if (option === service) 69 | entry.selected = true; 70 | select.appendChild(entry); 71 | }); 72 | }; 73 | }; 74 | 75 | function updatePrefOrder(e) { 76 | const other_selects = document.querySelectorAll( 77 | `#pref-table select:not([data-place="${e.target.dataset.place}"])` 78 | ); 79 | // Find select that has the value of the target select 80 | for (let i = 0; i < other_selects.length; i++) { 81 | if (other_selects[i].value === e.target.value) { 82 | // Set it to old value of target select 83 | all_values = [...document.querySelector('#pref-table select').options].map(e => e.value) 84 | used_values = new Set([...document.querySelectorAll('#pref-table select')].map(s => s.value)); 85 | open_value = all_values.filter(e => !used_values.has(e))[0]; 86 | other_selects[i].value = open_value; 87 | break; 88 | }; 89 | }; 90 | }; 91 | 92 | // code run on load 93 | usingApiKey() 94 | .then(api_key => { 95 | fillSettings(api_key); 96 | 97 | document.querySelector('#save-button').onclick = e => saveSettings(api_key); 98 | document.querySelector('#empty-download-folder').onclick = e => emptyFolder(api_key); 99 | }); 100 | -------------------------------------------------------------------------------- /frontend/static/js/settings_general.js: -------------------------------------------------------------------------------- 1 | function fillSettings(api_key) { 2 | fetchAPI('/settings', api_key) 3 | .then(json => { 4 | document.querySelector('#bind-address-input').value = json.result.host; 5 | document.querySelector('#port-input').value = json.result.port; 6 | document.querySelector('#url-base-input').value = json.result.url_base; 7 | document.querySelector('#password-input').value = json.result.auth_password; 8 | document.querySelector('#api-input').value = api_key; 9 | document.querySelector('#cv-input').value = json.result.comicvine_api_key; 10 | document.querySelector('#flaresolverr-input').value = json.result.flaresolverr_base_url; 11 | document.querySelector('#log-level-input').value = json.result.log_level; 12 | }); 13 | document.querySelector('#theme-input').value = getLocalStorage('theme')['theme']; 14 | }; 15 | 16 | function saveSettings(api_key) { 17 | document.querySelector("#save-button p").innerText = 'Saving'; 18 | document.querySelector('#cv-input').classList.remove('error-input'); 19 | document.querySelector("#flaresolverr-input").classList.remove('error-input'); 20 | const data = { 21 | 'host': document.querySelector('#bind-address-input').value, 22 | 'port': parseInt(document.querySelector('#port-input').value), 23 | 'url_base': document.querySelector('#url-base-input').value, 24 | 'auth_password': document.querySelector('#password-input').value, 25 | 'comicvine_api_key': document.querySelector('#cv-input').value, 26 | 'flaresolverr_base_url': document.querySelector('#flaresolverr-input').value, 27 | 'log_level': parseInt(document.querySelector('#log-level-input').value) 28 | }; 29 | sendAPI('PUT', '/settings', api_key, {}, data) 30 | .then(response => response.json()) 31 | .then(json => { 32 | if (json.error !== null) return Promise.reject(json); 33 | document.querySelector("#save-button p").innerText = 'Saved'; 34 | }) 35 | .catch(e => { 36 | document.querySelector("#save-button p").innerText = 'Failed'; 37 | if (e.error === 'InvalidComicVineApiKey') 38 | document.querySelector('#cv-input').classList.add('error-input'); 39 | 40 | else if ( 41 | e.error === "InvalidSettingValue" 42 | && e.result.key === "flaresolverr_base_url" 43 | ) 44 | document.querySelector("#flaresolverr-input").classList.add('error-input'); 45 | 46 | else 47 | console.log(e.error); 48 | }); 49 | }; 50 | 51 | function generateApiKey(api_key) { 52 | sendAPI('POST', '/settings/api_key', api_key) 53 | .then(response => response.json()) 54 | .then(json => { 55 | setLocalStorage({'api_key': json.result.api_key}); 56 | document.querySelector('#api-input').innerText = json.result.api_key; 57 | }); 58 | }; 59 | 60 | // code run on load 61 | 62 | usingApiKey() 63 | .then(api_key => { 64 | fillSettings(api_key); 65 | document.querySelector('#save-button').onclick = e => saveSettings(api_key); 66 | document.querySelector('#generate-api').onclick = e => generateApiKey(api_key); 67 | document.querySelector('#download-logs-button').href = 68 | `${url_base}/api/system/logs?api_key=${api_key}`; 69 | }); 70 | 71 | document.querySelector('#theme-input').onchange = e => { 72 | const value = document.querySelector('#theme-input').value; 73 | setLocalStorage({'theme': value}); 74 | if (value === 'dark') 75 | document.querySelector(':root').classList.add('dark-mode'); 76 | else if (value === 'light') 77 | document.querySelector(':root').classList.remove('dark-mode'); 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/static/js/status.js: -------------------------------------------------------------------------------- 1 | const StatEls = { 2 | version: document.querySelector('#version'), 3 | python_version: document.querySelector('#python-version'), 4 | database_version: document.querySelector('#database-version'), 5 | database_location: document.querySelector('#database-location'), 6 | data_folder: document.querySelector('#data-folder'), 7 | buttons: { 8 | copy: document.querySelector('#copy-about'), 9 | restart: document.querySelector('#restart-button'), 10 | shutdown: document.querySelector('#shutdown-button') 11 | } 12 | }; 13 | 14 | const about_table = ` 15 | | Key| Value | 16 | |--------|--------| 17 | | Kapowarr version | {k_version} | 18 | | Python version | {p_version} | 19 | | Database version | {d_version} | 20 | | Database location | {d_loc} | 21 | | Data folder | {folder} | 22 | 23 | `; 24 | 25 | // code run on load 26 | 27 | usingApiKey() 28 | .then(api_key => { 29 | fetchAPI('/system/about', api_key) 30 | .then(json => { 31 | StatEls.version.innerText = json.result.version; 32 | StatEls.python_version.innerText = json.result.python_version; 33 | StatEls.database_version.innerText = json.result.database_version; 34 | StatEls.database_location.innerText = json.result.database_location; 35 | StatEls.data_folder.innerText = json.result.data_folder; 36 | 37 | StatEls.buttons.copy.onclick = e => { 38 | copy(about_table 39 | .replace('{k_version}', json.result.version) 40 | .replace('{p_version}', json.result.python_version) 41 | .replace('{d_version}', json.result.database_version) 42 | .replace('{d_loc}', json.result.database_location) 43 | .replace('{folder}', json.result.data_folder) 44 | ); 45 | }; 46 | }); 47 | StatEls.buttons.restart.onclick = 48 | e => { 49 | StatEls.buttons.restart.innerText = 'Restarting'; 50 | sendAPI('POST', '/system/power/restart', api_key); 51 | }; 52 | StatEls.buttons.shutdown.onclick = 53 | e => { 54 | StatEls.buttons.shutdown.innerText = 'Shutting down'; 55 | sendAPI('POST', '/system/power/shutdown', api_key); 56 | }; 57 | }); 58 | 59 | 60 | function copy(text) { 61 | range = document.createRange(); 62 | selection = document.getSelection(); 63 | 64 | let container = document.createElement("span"); 65 | container.textContent = text; 66 | container.ariaHidden = true; 67 | container.style.all = "unset"; 68 | container.style.position = "fixed"; 69 | container.style.top = 0; 70 | container.style.clip = "rect(0, 0, 0, 0)"; 71 | container.style.whiteSpace = "pre"; 72 | container.style.userSelect = "text"; 73 | 74 | document.body.appendChild(container); 75 | 76 | try { 77 | range.selectNodeContents(container); 78 | selection.addRange(range); 79 | document.execCommand("copy"); 80 | StatEls.buttons.copy.innerText = 'Copied'; 81 | } 82 | catch (err) { 83 | // Failed 84 | StatEls.buttons.copy.innerText = 'Failed'; 85 | } 86 | finally { 87 | selection.removeAllRanges(); 88 | document.body.removeChild(container); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/static/js/tasks.js: -------------------------------------------------------------------------------- 1 | const TaskEls = { 2 | pre_build: { 3 | task: document.querySelector('.pre-build-els .task-entry'), 4 | history: document.querySelector('.pre-build-els .history-entry') 5 | }, 6 | intervals: document.querySelector('#task-intervals'), 7 | history: document.querySelector('#history'), 8 | buttons: { 9 | refresh: document.querySelector('#refresh-button'), 10 | clear: document.querySelector('#clear-button') 11 | } 12 | }; 13 | 14 | // 15 | // Task planning 16 | // 17 | function convertInterval(interval) { 18 | result = Math.round(interval / 3600); // seconds -> hours 19 | return `${result} hours`; 20 | }; 21 | 22 | function convertTime(epoch, future) { 23 | result = Math.round(Math.abs(Date.now() / 1000 - epoch) / 3600); // delta hours 24 | if (future) return `in ${result} hours`; 25 | else return `${result} hours ago`; 26 | }; 27 | 28 | function fillPlanning(api_key) { 29 | fetchAPI('/system/tasks/planning', api_key) 30 | .then(json => { 31 | TaskEls.intervals.innerHTML = ''; 32 | json.result.forEach(e => { 33 | const entry = TaskEls.pre_build.task.cloneNode(true); 34 | 35 | entry.querySelector('.name-column').innerText = e.display_name; 36 | entry.querySelector('.interval-column').innerText = 37 | convertInterval(e.interval); 38 | entry.querySelector('.prev-column').innerText = 39 | convertTime(e.last_run, false); 40 | entry.querySelector('.next-column').innerText = 41 | convertTime(e.next_run, true); 42 | 43 | TaskEls.intervals.appendChild(entry); 44 | }); 45 | }); 46 | }; 47 | 48 | // 49 | // Task history 50 | // 51 | function fillHistory(api_key) { 52 | fetchAPI('/system/tasks/history', api_key) 53 | .then(json => { 54 | TaskEls.history.innerHTML = ''; 55 | json.result.forEach(obj => { 56 | const entry = TaskEls.pre_build.history.cloneNode(true); 57 | 58 | entry.querySelector('.title-column').innerText = obj.display_title; 59 | 60 | var d = new Date(obj.run_at * 1000); 61 | var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5) 62 | entry.querySelector('.date-column').innerText = formatted_date; 63 | 64 | TaskEls.history.appendChild(entry); 65 | }); 66 | }); 67 | }; 68 | 69 | function clearHistory(api_key) { 70 | sendAPI('DELETE', '/system/tasks/history', api_key) 71 | TaskEls.history.innerHTML = ''; 72 | }; 73 | 74 | // code run on load 75 | 76 | usingApiKey() 77 | .then(api_key => { 78 | fillHistory(api_key); 79 | fillPlanning(api_key); 80 | TaskEls.buttons.refresh.onclick = e => fillHistory(api_key); 81 | TaskEls.buttons.clear.onclick = e => clearHistory(api_key); 82 | }); 83 | -------------------------------------------------------------------------------- /frontend/static/js/window.js: -------------------------------------------------------------------------------- 1 | function showWindow(id) { 2 | // Deselect all windows 3 | document.querySelectorAll('.window > section').forEach(window => { 4 | window.removeAttribute('show-window'); 5 | }); 6 | 7 | // Select the correct window 8 | document.querySelector(`.window > section#${id}`).setAttribute('show-window', ''); 9 | 10 | // Show the window 11 | document.querySelector('.window').setAttribute('show-window', ''); 12 | }; 13 | 14 | function showLoadWindow(id) { 15 | // Deselect all windows 16 | document.querySelectorAll('.window > section').forEach(window => { 17 | window.removeAttribute('show-window'); 18 | }); 19 | 20 | // Select the correct window 21 | const loading_window = document.querySelector(`.window > section#${id}`).dataset.loading_window; 22 | if (loading_window !== undefined) document.querySelector(`.window > section#${loading_window}`).setAttribute('show-window', ''); 23 | 24 | // Show the window 25 | document.querySelector('.window').setAttribute('show-window', ''); 26 | }; 27 | 28 | function closeWindow() { 29 | document.querySelector('.window').removeAttribute('show-window'); 30 | }; 31 | 32 | // code run on load 33 | 34 | document.querySelector('body').onkeydown = e => { 35 | if ( 36 | e.code === "Escape" 37 | && 38 | document.querySelector('.window[show-window]') 39 | ) { 40 | e.stopImmediatePropagation(); 41 | closeWindow(); 42 | }; 43 | }; 44 | 45 | document.querySelector('.window').onclick = e => { 46 | e.stopImmediatePropagation(); 47 | closeWindow(); 48 | }; 49 | 50 | document.querySelectorAll('.window > section').forEach( 51 | el => el.onclick = e => e.stopImmediatePropagation() 52 | ); 53 | 54 | document.querySelectorAll( 55 | '.window > section :where(button[title="Cancel"], button.cancel-window)' 56 | ).forEach(e => { 57 | e.onclick = f => closeWindow(); 58 | }); 59 | -------------------------------------------------------------------------------- /frontend/templates/add_volume.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button, window, loading_window %} 3 | {% block title %}Add Volume{% endblock %} 4 | {% block css %} 5 | 6 | 7 | {% endblock %} 8 | {% block js %} 9 | 10 | 11 | {% endblock %} 12 | 13 | {% block pre_build_els %} 14 | 38 | {% endblock pre_build_els %} 39 | 40 | {% block windows %} 41 | {{ loading_window("loading-window", "Adding volume...") }} 42 | 43 | {% set add_content %} 44 | 45 |
46 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 69 | 70 | 71 | 72 | 79 | 80 | 81 | 82 | 90 | 91 | 92 | 93 | 103 | 104 |
51 | 53 |
58 | 59 |
64 | 68 |
73 | 77 |

When new issues come out, automatically monitor them.

78 |
83 | 88 |

Choose which issues to monitor that have already been released.

89 |
94 | 102 |
105 |
106 | {% endset %} 107 | 108 | {% set add_submit %} 109 | 110 | 111 | 112 | {% endset %} 113 | 114 | {{ window(False, "add-window", "Add volume", add_content, add_submit, "loading-window", False) }} 115 | 116 | {% endblock windows %} 117 | 118 | {% block main %} 119 |
120 |
121 | 130 |
131 |

It's easy to add a new volume, just start typing the name of the volume you want to add

132 |
133 |

You can also search using the CV ID of a volume. e.g. 'cv:4050-2127', 'cv:2127' or '4050-2127'

134 |
135 | 138 | 141 | 145 | 149 | 165 |
166 |
167 | {% endblock main %} 168 | -------------------------------------------------------------------------------- /frontend/templates/blocklist.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button, page_turner %} 3 | {% block title %}Blocklist{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block pre_build_rows %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | {% endblock %} 25 | 26 | {% block main %} 27 |
28 |
29 |
30 | {{ icon_button("refresh-button", "Refresh the list", "refresh.svg", "Refresh") }} 31 | {{ icon_button("clear-button", "Empty the complete blocklist", "delete.svg", "Clear Blocklist") }} 32 |
33 |
34 |
35 | {{ page_turner() }} 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
LinkReasonAdded atDelete
48 |
49 |
50 |
51 | {% endblock main %} 52 | -------------------------------------------------------------------------------- /frontend/templates/history.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button, page_turner %} 3 | {% block title %}History{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block pre_build_rows %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% endblock %} 23 | 24 | {% block main %} 25 |
26 |
27 |
28 | {{ icon_button("refresh-button", "Refresh the list", "refresh.svg", "Refresh") }} 29 | {{ icon_button("clear-button", "Clear History", "delete.svg", "Clear History") }} 30 |
31 |
32 |
33 | {{ page_turner() }} 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
LinkFileSourceDownloaded At
46 |
47 |
48 |
49 | {% endblock main %} 50 | -------------------------------------------------------------------------------- /frontend/templates/library_import.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import window %} 3 | {% block title %}Library Import{% endblock %} 4 | {% block css %} 5 | 6 | 7 | {% endblock %} 8 | {% block js %} 9 | 10 | 11 | {% endblock %} 12 | 13 | {% block pre_build_rows %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% endblock pre_build_rows %} 38 | 39 | {% block windows %} 40 | {% set edit_cv_content %} 41 | 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
Search ResultIssue CountAction
56 |
57 | {% endset %} 58 | 59 | {{ window(False, "cv-window", "Edit ComicVine Match", edit_cv_content) }} 60 | {% endblock windows %} 61 | 62 | {% block main %} 63 |
64 |
65 |

Import an existing organized library to add volumes to Kapowarr

66 |

A few notes:

67 | 73 | 74 | 75 | 76 | 77 | 86 | 87 | 88 | 89 | 95 | 96 | 97 | 98 | 104 | 105 | 106 | 107 | 113 | 114 | 115 | 119 | 120 | 121 |
78 | 85 |
90 | 94 |
99 | 103 |
108 | 112 |
116 | 117 | 118 |
122 | 123 |
124 | 127 | 131 | 136 | 157 |
158 | {% endblock %} 159 | -------------------------------------------------------------------------------- /frontend/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Login - Kapowarr 17 | 18 | 19 |
20 |

Kapowarr

21 |

Please log in

22 |
23 | 26 |
27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 | -------------------------------------------------------------------------------- /frontend/templates/page_not_found.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | Not found - Kapowarr 19 | 20 | 21 |

Page not found

22 | 23 | -------------------------------------------------------------------------------- /frontend/templates/queue.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button %} 3 | {% block title %}Queue{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block pre_build_rows %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 30 | 33 | 36 | 37 | 38 | {% endblock %} 39 | 40 | {% block main %} 41 |
42 |
43 |
44 | {{ icon_button( 45 | "removeall-button", 46 | "Remove all downloads from the queue", 47 | "delete.svg", 48 | "Remove All" 49 | ) }} 50 |
51 |
52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
StatusTitleSourceSizeSpeedProgressActions
67 |
68 |
69 |

Queue empty

70 |
71 |
72 | {% endblock main %} 73 | -------------------------------------------------------------------------------- /frontend/templates/settings_download.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button %} 3 | {% block title %}Download Settings{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
13 |
14 |
15 | {{ icon_button("save-button", "Save the changes made", "save.svg", "Save", True) }} 16 |
17 |
18 |
19 |
20 |

Download Location

21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 36 | 37 | 38 |
26 | 27 |

The folder that direct downloads temporarily get downloaded to before being moved to the correct location

28 |
33 | 34 |

Removes any "ghost" files in the direct download temporary folder (handy if the application crashed while downloading, leading to half-downloaded files in the folder).

35 |
39 |

Queue

40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | 58 | 65 | 66 | 67 | 68 | 72 | 73 | 74 |
45 | 46 |

The amount of direct downloads that are allowed to run at the same time.

47 |
52 | 53 |

Time in minutes that the download is stalled before it's considered to be failing and is removed from the queue. Set empty to disable.

54 |
59 | 63 |

How a torrent that goes seeding should be handled. Either wait until it has completed seeding and then move the files, or copy the files and then delete the original when seeding finishes.

64 |
69 | 70 |

Remove external downloads from their download client history when they have completed.

71 |
75 |

Service preference

76 |

The preference for service to download from when using GetComics as the source.

77 | 78 | 79 | 80 | 81 | 85 | 86 | 87 | 88 | 92 | 93 | 94 | 95 | 99 | 100 | 101 | 102 | 106 | 107 | 108 | 109 | 113 | 114 | 115 | 116 | 120 | 121 | 122 |
1 82 | 84 |
2 89 | 91 |
3 96 | 98 |
4 103 | 105 |
5 110 | 112 |
6 117 | 119 |
123 |
124 |
125 |
126 | {% endblock main %} 127 | -------------------------------------------------------------------------------- /frontend/templates/settings_general.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button %} 3 | {% block title %}General Settings{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
13 |
14 |
15 | {{ icon_button("save-button", "Save the changes made", "save.svg", "Save", True) }} 16 |
17 |
18 |
19 |
20 |

Host

21 |

Changing hosting settings will immediately restart Kapowarr. Access the web-ui within 60 seconds for the changes to stay, or they will be reverted.

22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 43 | 44 |
26 | 27 |

Valid IPv4 address (default is '0.0.0.0' for all available interfaces).

28 |
33 | 34 |

The port used to access the web UI (default is '5656').

35 |
40 | 41 |

For reverse proxy support (default is empty).

42 |
45 |

Security

46 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | 62 | 63 |
50 | 51 |

When set, a password is required to be entered to get access to web-UI and API.

52 |
57 |
58 | 59 | 60 |
61 |
64 |

External Websites

65 | 66 | 67 | 68 | 72 | 73 | 74 | 75 | 79 | 80 |
69 | 70 |

The API key of the Comic Vine API, which is required for Kapowarr to work.

71 |
76 | 77 |

Base URL of a FlareSolverr instance (set empty to disable). Needed in case of a large amount of requests to CloudFlare protected websites.

78 |
81 |

UI

82 | 83 | 84 | 85 | 91 | 92 |
86 | 90 |
93 |

Logging

94 | 95 | 96 | 97 | 104 | 105 | 106 | 107 | 110 | 111 |
98 | 102 |

Change this to 'Debug' if detailed logging is needed for troubleshooting. Change it back to 'Info' when done.

103 |
108 | Download Logs 109 |
112 |
113 |
114 |
115 | {% endblock main %} 116 | -------------------------------------------------------------------------------- /frontend/templates/status.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Status{% endblock %} 3 | {% block css %} 4 | 5 | {% endblock %} 6 | {% block js %} 7 | 8 | {% endblock %} 9 | 10 | {% block main %} 11 |
12 |
13 |
14 |

15 | About 16 | 17 |

18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 |
44 |

Power

45 | 49 |
50 |
51 |

Donate

52 | 62 |
63 |
64 |

Contact

65 | 79 |
80 |
81 |
82 | {% endblock main %} 83 | -------------------------------------------------------------------------------- /frontend/templates/tasks.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "base.html" import icon_button %} 3 | {% block title %}Tasks{% endblock %} 4 | {% block css %} 5 | 6 | {% endblock %} 7 | {% block js %} 8 | 9 | {% endblock %} 10 | 11 | {% block pre_build_rows %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% endblock %} 23 | 24 | {% block main %} 25 |
26 |
27 |
28 | {{ icon_button("refresh-button", "Refresh the list", "refresh.svg", "Refresh") }} 29 | {{ icon_button("clear-button", "Clear Task History", "delete.svg", "Clear History") }} 30 |
31 |
32 |
33 |

Scheduled

34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
NameIntervalLast ExecutionNext Execution
46 |
47 |

History

48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
TitleDate
57 |
58 |
59 | {% endblock main %} 60 | -------------------------------------------------------------------------------- /frontend/ui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from io import BytesIO 4 | from json import dumps 5 | from typing import Any 6 | 7 | from flask import Blueprint, redirect, render_template, send_file 8 | 9 | from backend.internals.server import SERVER 10 | 11 | ui = Blueprint('ui', __name__) 12 | methods = ['GET'] 13 | 14 | 15 | def render(filename: str, **kwargs: Any) -> str: 16 | return render_template(filename, url_base=SERVER.url_base, **kwargs) 17 | 18 | 19 | @ui.route('/manifest.json', methods=methods) 20 | def ui_manifest(): 21 | return send_file( 22 | BytesIO(dumps( 23 | { 24 | "name": "Kapowarr", 25 | "short_name": "Kapowarr", 26 | "description": "Kapowarr is a software to build and manage a comic book library, fitting in the *arr suite of software.", 27 | "display": "standalone", 28 | "orientation": "portrait-primary", 29 | "start_url": f"{SERVER.url_base}/", 30 | "scope": f"{SERVER.url_base}/", 31 | "id": f"{SERVER.url_base}/", 32 | "background_color": "#464b51", 33 | "theme_color": "#ebc700", 34 | "icons": [ 35 | { 36 | "src": f"{SERVER.url_base}/static/img/favicon.svg", 37 | "type": "image/svg+xml", 38 | "sizes": "any" 39 | } 40 | ] 41 | }, 42 | indent=4 43 | ).encode('utf-8')), 44 | mimetype="application/manifest+json", 45 | download_name="manifest.json" 46 | ), 200 47 | 48 | 49 | @ui.route('/login', methods=methods) 50 | def ui_login(): 51 | return render('login.html') 52 | 53 | 54 | @ui.route('/', methods=methods) 55 | def ui_volumes(): 56 | return render('volumes.html') 57 | 58 | 59 | @ui.route('/add', methods=methods) 60 | def ui_add_volume(): 61 | return render('add_volume.html') 62 | 63 | 64 | @ui.route('/library-import', methods=methods) 65 | def ui_library_import(): 66 | return render('library_import.html') 67 | 68 | 69 | @ui.route('/volumes/', methods=methods) 70 | def ui_view_volume(id): 71 | return render('view_volume.html') 72 | 73 | 74 | @ui.route('/activity/queue', methods=methods) 75 | def ui_queue(): 76 | return render('queue.html') 77 | 78 | 79 | @ui.route('/activity/history', methods=methods) 80 | def ui_history(): 81 | return render('history.html') 82 | 83 | 84 | @ui.route('/activity/blocklist', methods=methods) 85 | def ui_blocklist(): 86 | return render('blocklist.html') 87 | 88 | 89 | @ui.route('/system/status', methods=methods) 90 | def ui_status(): 91 | return render('status.html') 92 | 93 | 94 | @ui.route('/system/tasks', methods=methods) 95 | def ui_tasks(): 96 | return render('tasks.html') 97 | 98 | 99 | @ui.route('/settings', methods=methods) 100 | def ui_settings(): 101 | return redirect(f'{SERVER.url_base}/settings/mediamanagement') 102 | 103 | 104 | @ui.route('/settings/mediamanagement', methods=methods) 105 | def ui_mediamanagement(): 106 | return render('settings_mediamanagement.html') 107 | 108 | 109 | @ui.route('/settings/download', methods=methods) 110 | def ui_download(): 111 | return render('settings_download.html') 112 | 113 | 114 | @ui.route('/settings/downloadclients', methods=methods) 115 | def ui_download_clients(): 116 | return render('settings_download_clients.html') 117 | 118 | 119 | @ui.route('/settings/general', methods=methods) 120 | def ui_general(): 121 | return render('settings_general.html') 122 | -------------------------------------------------------------------------------- /project_management/docs-requirements.txt: -------------------------------------------------------------------------------- 1 | wheel>=0.38.4 2 | mkdocs-material>=8.5.11 3 | mkdocs-redirects>=1.2.0 4 | mkdocs-git-revision-date-localized-plugin>=1.1.0 5 | Pygments>=2.13.0 6 | pymdown-extensions>=9.9 7 | -------------------------------------------------------------------------------- /project_management/mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Site Details 2 | site_name: Kapowarr Docs 3 | site_description: The official guide for Kapowarr 4 | site_author: Casvt 5 | repo_url: https://github.com/Casvt/Kapowarr 6 | edit_uri: blob/development/docs/ 7 | repo_name: Casvt/Kapowarr 8 | docs_dir: ../docs 9 | 10 | 11 | 12 | extra_css: 13 | - assets/css/extra.css 14 | 15 | # Site appearance (logos, colours, icons) 16 | theme: 17 | name: material 18 | language: en 19 | code_wrap: true 20 | features: 21 | - content.tabs.link 22 | - content.code.copy 23 | - header.autohide 24 | - navigation.expand 25 | - navigation.indexes 26 | - navigation.instant 27 | - navigation.sections 28 | - navigation.tabs 29 | - navigation.tabs.sticky 30 | - navigation.top 31 | - navigation.tracking 32 | favicon: assets/img/favicon.svg 33 | logo: assets/img/favicon.svg 34 | palette: 35 | # Light mode 36 | - media: "(prefers-color-scheme: light)" 37 | scheme: Kapowarr 38 | toggle: 39 | icon: material/toggle-switch-off-outline 40 | name: Switch to dark mode 41 | # Dark mode 42 | - media: "(prefers-color-scheme: dark)" 43 | scheme: Kapowarr-dark 44 | toggle: 45 | icon: material/toggle-switch 46 | name: Switch to light mode 47 | 48 | # Markdown extensions 49 | markdown_extensions: 50 | - abbr 51 | - admonition 52 | - attr_list 53 | - meta 54 | - pymdownx.details 55 | - pymdownx.highlight: 56 | guess_lang: true 57 | anchor_linenums: true 58 | - pymdownx.inlinehilite 59 | - pymdownx.keys 60 | - pymdownx.saneheaders 61 | - pymdownx.smartsymbols 62 | - pymdownx.snippets 63 | - pymdownx.superfences 64 | - pymdownx.tabbed: 65 | alternate_style: true 66 | - sane_lists 67 | - toc: 68 | permalink: true 69 | toc_depth: 3 70 | - tables 71 | 72 | # mkdocs function extensions 73 | plugins: 74 | - search 75 | - git-revision-date-localized: 76 | type: timeago 77 | locale: en 78 | fallback_to_build_date: false 79 | - redirects: 80 | redirect_maps: 81 | 'library-import.md': 'general_info/features.md#library-import' 82 | 83 | # Navigation Layout 84 | nav: 85 | - Home: index.md 86 | - Installation And Updating: 87 | - Introduction: installation/installation.md 88 | - Docker: installation/docker.md 89 | - Manual Install: installation/manual_install.md 90 | - Setup After Installation: installation/setup_after_installation.md 91 | - General Information: 92 | - Introduction: general_info/workings.md 93 | - Managing A Volume: general_info/managing_volume.md 94 | - Downloading: general_info/downloading.md 95 | - Matching: general_info/matching.md 96 | - Features: general_info/features.md 97 | - Beta Documentation: 98 | - Documentation: beta/beta.md 99 | - Settings: 100 | - Introduction: settings/settings.md 101 | - Media Management: settings/mediamanagement.md 102 | - Download: settings/download.md 103 | - Download Clients: settings/downloadclients.md 104 | - General: settings/general.md 105 | - FAQ: other_docs/faq.md 106 | - Other Docs: 107 | - Reporting: other_docs/reporting.md 108 | - FAQ: other_docs/faq.md 109 | - Rate Limiting: other_docs/rate_limiting.md 110 | - API: other_docs/api.md -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Kapowarr" 3 | version = "1.2.0" 4 | description = "Kapowarr is a software to build and manage a comic book library, fitting in the *arr suite of software." 5 | authors = [ 6 | {name = "Cas van Tijn"} 7 | ] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | requires-python = ">= 3.8" 11 | 12 | [tool.mypy] 13 | warn_unused_configs = true 14 | sqlite_cache = true 15 | cache_fine_grained = true 16 | 17 | ignore_missing_imports = true 18 | disable_error_code = ["abstract", "annotation-unchecked", "arg-type", "assert-type", "assignment", "attr-defined", "await-not-async", "call-arg", "call-overload", "dict-item", "empty-body", "exit-return", "func-returns-value", "has-type", "import", "import-not-found", "import-untyped", "index", "list-item", "literal-required", "method-assign", "misc", "name-defined", "name-match", "no-overload-impl", "no-redef", "operator", "override", "return", "return-value", "safe-super", "str-bytes-safe", "str-format", "syntax", "top-level-await", "truthy-function", "type-abstract", "type-var", "typeddict-item", "typeddict-unknown-key", "union-attr", "unused-coroutine", "used-before-def", "valid-newtype", "valid-type", "var-annotated"] 19 | enable_error_code = ["method-assign", "func-returns-value", "name-match", "no-overload-impl", "unused-coroutine", "top-level-await", "await-not-async", "str-format", "redundant-expr", "unused-awaitable"] 20 | 21 | [tool.isort] 22 | balanced_wrapping = true 23 | combine_as_imports = true 24 | combine_star = true 25 | honor_noqa = true 26 | remove_redundant_aliases = true 27 | 28 | [tool.autopep8] 29 | aggressive = 3 30 | experimental = true 31 | max_line_length = 80 32 | ignore = ["E124", "E125", "E126", "E128", "E261"] 33 | 34 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | autopep8 ~= 2.2 2 | isort ~= 5.13 3 | mypy ~= 1.10 4 | pre-commit ~= 3.5 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | typing_extensions ~= 4.12 2 | requests ~= 2.31 3 | beautifulsoup4 ~= 4.12 4 | flask ~= 3.0 5 | waitress ~= 3.0 6 | cryptography ~= 44.0, >= 44.0.1 7 | bencoding ~= 0.2 8 | aiohttp ~= 3.9 9 | flask-socketio ~= 5.3 10 | websocket-client ~= 1.3 11 | -------------------------------------------------------------------------------- /tests/Tbackend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Casvt/Kapowarr/4c518ac53cbde4fbae3717737ef49aac7fb71f4c/tests/Tbackend/__init__.py --------------------------------------------------------------------------------