├── .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 | [](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 | 
30 | 
31 | 
32 | 
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 |
25 |
26 |
27 |
28 |
29 |
35 |
36 |
37 |
38 | {% endblock pre_build_els %}
39 |
40 | {% block windows %}
41 | {{ loading_window("loading-window", "Adding volume...") }}
42 |
43 | {% set add_content %}
44 |
45 |
106 | {% endset %}
107 |
108 | {% set add_submit %}
109 | Start search for missing volume
110 |
111 | Add Volume
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 |
142 | The Comic Vine api key is not set or is invalid
143 | Please set a working Comic Vine api key at Settings -> General -> Comic Vine API
144 |
145 |
146 | You can't add volumes before having added at least one root folder
147 | A root folder can be added at Settings -> Media Management -> Root Folders
148 |
149 |
150 |
151 |
152 | All Languages
153 | Only English
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
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 |
20 |
21 |
22 |
23 |
24 | {% endblock %}
25 |
26 | {% block main %}
27 |
28 |
34 |
35 | {{ page_turner() }}
36 |
37 |
38 |
39 |
40 | Link
41 | Reason
42 | Added at
43 | Delete
44 |
45 |
46 |
47 |
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 |
32 |
33 | {{ page_turner() }}
34 |
35 |
36 |
37 |
38 | Link
39 | File
40 | Source
41 | Downloaded At
42 |
43 |
44 |
45 |
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 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Select
35 | Select for group
36 |
37 | {% endblock pre_build_rows %}
38 |
39 | {% block windows %}
40 | {% set edit_cv_content %}
41 |
47 |
48 |
49 |
50 | Search Result
51 | Issue Count
52 | Action
53 |
54 |
55 |
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 |
68 | Check if Kapowarr matched correctly! Comicvine has separate releases for normal volumes, TPB's, one shots, hard covers, etc. So even though the name and year match, it could still be a wrong match.
69 | Importing a lot of volumes in one go will quickly make Kapowarr reach the rate limit of ComicVine. Import volumes in batches to avoid this. It's advised to not go above 50 volumes at a time.
70 | Files are not allowed to be directly in the root folder. They have to be in a sub-folder.
71 | If each issue has a separate sub-folder, enable 'Apply limit to parent folder' so that the limit is correctly applied.
72 |
73 |
122 |
Scan
123 |
124 |
125 |
Loading...
126 |
127 |
128 |
No results
129 | Go Back
130 |
131 |
132 |
Set ComicVine API Key first in the settings
133 |
Go Back
134 |
Go to settings
135 |
136 |
137 |
138 | Cancel
139 | Import
140 | Import and Rename
141 |
142 |
156 |
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 |
24 | WARNING: This web-UI does not work without JavaScript.
25 |
26 |
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 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {% endblock %}
39 |
40 | {% block main %}
41 |
42 |
52 |
53 |
54 |
55 |
56 | Status
57 | Title
58 | Source
59 | Size
60 | Speed
61 | Progress
62 | Actions
63 |
64 |
65 |
66 |
67 |
68 |
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 |
18 |
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 |
18 |
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 | Copy
17 |
18 |
19 |
20 |
21 | Kapowarr version
22 |
23 |
24 |
25 | Python version
26 |
27 |
28 |
29 | Database version
30 |
31 |
32 |
33 | Database location
34 |
35 |
36 |
37 | Data folder
38 |
39 |
40 |
41 |
42 |
43 |
44 | Power
45 |
46 | Restart
47 | Shutdown
48 |
49 |
50 |
63 |
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 |
32 |
33 |
Scheduled
34 |
35 |
36 |
37 |
38 | Name
39 | Interval
40 | Last Execution
41 | Next Execution
42 |
43 |
44 |
45 |
46 |
47 |
History
48 |
49 |
50 |
51 | Title
52 | Date
53 |
54 |
55 |
56 |
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
--------------------------------------------------------------------------------