From 464b48f6b0e7b8695d7814ced04e6c438f93c137 Mon Sep 17 00:00:00 2001
From: Casvt <88994465+Casvt@users.noreply.github.com>
Date: Wed, 29 Nov 2023 15:16:28 +0100
Subject: [PATCH] Added Mass editor (#51)
* Added base for mass editor
* Added functionality for each action in backend
---
backend/mass_edit.py | 81 +++++++++++++++
frontend/api.py | 38 ++++++-
frontend/static/css/mass_editor.css | 131 +++++++++++++++++++++++++
frontend/static/js/mass_editor.js | 92 +++++++++++++++++
frontend/templates/add_volume.html | 1 +
frontend/templates/library_import.html | 1 +
frontend/templates/mass_editor.html | 113 +++++++++++++++++++++
frontend/templates/view_volume.html | 1 +
frontend/templates/volumes.html | 1 +
frontend/ui.py | 4 +
10 files changed, 462 insertions(+), 1 deletion(-)
create mode 100644 backend/mass_edit.py
create mode 100644 frontend/static/css/mass_editor.css
create mode 100644 frontend/static/js/mass_editor.js
create mode 100644 frontend/templates/mass_editor.html
diff --git a/backend/mass_edit.py b/backend/mass_edit.py
new file mode 100644
index 00000000..d2aad4e4
--- /dev/null
+++ b/backend/mass_edit.py
@@ -0,0 +1,81 @@
+#-*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, List, Union
+
+from backend.custom_exceptions import InvalidKeyValue, VolumeDownloadedFor
+from backend.db import get_db
+from backend.naming import mass_rename
+from backend.search import auto_search
+from backend.volumes import Volume, refresh_and_scan
+
+if TYPE_CHECKING:
+ from backend.download_queue import DownloadHandler
+
+class MassEditorVariables:
+ download_handler: Union[None, DownloadHandler] = None
+
+def mass_editor_delete(volume_ids: List[int], **kwargs) -> None:
+ delete_volume_folder = kwargs.get('delete_folder', False)
+ if not isinstance(delete_volume_folder, bool):
+ raise InvalidKeyValue('delete_folder', delete_volume_folder)
+
+ logging.info(f'Using mass editor, deleting volumes: {volume_ids}')
+
+ for volume_id in volume_ids:
+ try:
+ Volume(volume_id).delete(delete_volume_folder)
+ except VolumeDownloadedFor:
+ continue
+ return
+
+def mass_editor_rename(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, renaming volumes: {volume_ids}')
+ for volume_id in volume_ids:
+ mass_rename(volume_id)
+ return
+
+def mass_editor_update(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, updating volumes: {volume_ids}')
+ for volume_id in volume_ids:
+ refresh_and_scan(volume_id)
+ return
+
+def mass_editor_search(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, auto searching for volumes: {volume_ids}')
+ for volume_id in volume_ids:
+ search_results = auto_search(volume_id)
+ for result in search_results:
+ MassEditorVariables.download_handler.add(
+ result['link'],
+ volume_id
+ )
+ return
+
+def mass_editor_convert(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, converting for volumes: {volume_ids}')
+ return
+
+def mass_editor_unmonitor(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, unmonitoring volumes: {volume_ids}')
+ for volume_id in volume_ids:
+ Volume(volume_id)._unmonitor()
+ return
+
+def mass_editor_monitor(volume_ids: List[int], **kwargs) -> None:
+ logging.info(f'Using mass editor, monitoring volumes: {volume_ids}')
+ for volume_id in volume_ids:
+ Volume(volume_id)._monitor()
+ return
+
+action_to_func = {
+ 'delete': mass_editor_delete,
+ 'rename': mass_editor_rename,
+ 'update': mass_editor_update,
+ 'search': mass_editor_search,
+ 'convert': mass_editor_convert,
+ 'unmonitor': mass_editor_unmonitor,
+ 'monitor': mass_editor_monitor
+}
diff --git a/frontend/api.py b/frontend/api.py
index 54e2b4ad..8261eb25 100644
--- a/frontend/api.py
+++ b/frontend/api.py
@@ -1,7 +1,7 @@
#-*- coding: utf-8 -*-
import logging
-from typing import Any, Tuple
+from typing import Any, Dict, List, Tuple
from flask import Blueprint, Flask, request, send_file
@@ -31,6 +31,7 @@
get_download_history)
from backend.download_torrent_clients import TorrentClients, client_types
from backend.library_import import import_library, propose_library_import
+from backend.mass_edit import MassEditorVariables, action_to_func
from backend.naming import (generate_volume_folder_name, mass_rename,
preview_mass_rename)
from backend.root_folders import RootFolders
@@ -50,6 +51,7 @@
handler_context.teardown_appcontext(close_db)
download_handler = DownloadHandler(handler_context)
task_handler = TaskHandler(handler_context, download_handler)
+MassEditorVariables.download_handler = download_handler
def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]:
return {'error': error, 'result': result}, code
@@ -796,3 +798,37 @@ def api_torrent_client(id: int):
elif request.method == 'DELETE':
client.delete()
return return_api({})
+
+#=====================
+# Torrent Clients
+#=====================
+@api.route('/masseditor', methods=['POST'])
+@error_handler
+@auth
+def api_mass_editor():
+ data = request.get_json()
+ if not isinstance(data, dict):
+ raise InvalidKeyValue('body', data)
+ if not 'volume_ids' in data:
+ raise KeyNotFound('volume_ids')
+ if not 'action' in data:
+ raise KeyNotFound('action')
+
+ action: str = data['action']
+ volume_ids: List[int] = data['volume_ids']
+ args: Dict[str, Any] = data.get('args', {})
+
+ if not (
+ isinstance(volume_ids, list)
+ and all(isinstance(v, int) for v in volume_ids)
+ ):
+ raise InvalidKeyValue('volume_ids', volume_ids)
+
+ if not action in action_to_func:
+ raise InvalidKeyValue('action', action)
+
+ if not isinstance(args, dict):
+ raise InvalidKeyValue('args', args)
+
+ action_to_func[action](volume_ids, **args)
+ return return_api({})
diff --git a/frontend/static/css/mass_editor.css b/frontend/static/css/mass_editor.css
new file mode 100644
index 00000000..130a32e3
--- /dev/null
+++ b/frontend/static/css/mass_editor.css
@@ -0,0 +1,131 @@
+.nav-main > main > div {
+ flex: none;
+}
+
+main {
+ color: var(--text-color);
+ padding: 1rem;
+}
+
+#loading-window {
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+#loading-window h2 {
+ font-size: clamp(1rem, 5vw, 2rem);
+ font-weight: 500;
+ text-align: center;
+}
+
+#list-window {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.action-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+
+ padding: .5rem;
+ border-radius: 4px;
+ background-color: var(--tool-bar-color);
+ color: var(--light-color);
+}
+
+.action-divider {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.action-bar button {
+ min-width: 5rem;
+
+ border: 2px solid var(--border-color);
+ border-radius: 4px;
+ padding: .4rem .5rem;
+ color: var(--text-color);
+ background-color: var(--library-entry-color);
+
+ transition: background-color 100ms linear;
+}
+
+.action-bar button:hover {
+ background-color: var(--dark-hover-color);
+}
+
+.option-container {
+ overflow: hidden;
+ border-radius: 4px;
+ border: 2px solid var(--border-color);
+ background-color: var(--library-entry-color);
+}
+
+.option-container button {
+ border-radius: none;
+ border: none;
+}
+
+.option-container select {
+ height: 100%;
+ background-color: var(--library-entry-color);
+ color: var(--text-color);
+}
+
+.list-container {
+ position: relative;
+
+ width: 100%;
+ overflow-x: auto;
+
+ padding-top: 1rem;
+}
+
+table {
+ width: max(100%, 30rem);
+ border-spacing: 0px;
+}
+
+th {
+ text-align: left;
+}
+
+th,
+td {
+ padding: .5rem;
+ border-bottom: 1px solid var(--nav-color);
+}
+
+th:first-child,
+td:first-child {
+ width: 3rem;
+ padding-inline: 1rem;
+}
+
+tbody > tr {
+ transition: background-color 150ms ease-in-out;
+}
+
+tbody > tr:hover {
+ background-color: var(--dark-hover-color);
+}
+
+@media (max-width: 820px) {
+ .action-bar {
+ justify-content: center;
+ }
+ .action-divider {
+ width: 100%;
+ }
+ .option-container {
+ flex: 1 1 auto;
+ }
+ .action-bar button {
+ flex: 1 1 0;
+ }
+}
diff --git a/frontend/static/js/mass_editor.js b/frontend/static/js/mass_editor.js
new file mode 100644
index 00000000..27c681de
--- /dev/null
+++ b/frontend/static/js/mass_editor.js
@@ -0,0 +1,92 @@
+const windows = {
+ loading: document.querySelector('#loading-window'),
+ list: document.querySelector('#list-window')
+};
+
+function fillVolumeList(api_key) {
+ document.querySelector('#selectall-input').checked = false;
+ const table = document.querySelector('.volume-list');
+ table.innerHTML = '';
+ fetch(`${url_base}/api/volumes?api_key=${api_key}`)
+ .then(response => response.json())
+ .then(json => {
+ json.result.forEach(vol => {
+ const entry = document.createElement('tr');
+ entry.dataset.id = vol.id;
+
+ const select_container = document.createElement('td');
+ const select = document.createElement('input');
+ select.type = 'checkbox';
+ select.checked = false;
+ select_container.appendChild(select);
+ entry.appendChild(select_container);
+
+ const title = document.createElement('td');
+ title.innerText = vol.title;
+ entry.appendChild(title);
+
+ const year = document.createElement('td');
+ year.innerText = vol.year;
+ entry.appendChild(year);
+
+ const volume_number = document.createElement('td');
+ volume_number.innerText = vol.volume_number;
+ entry.appendChild(volume_number);
+
+ table.appendChild(entry);
+ });
+ });
+};
+
+function toggleSelection() {
+ const checked = document.querySelector('#selectall-input').checked;
+ document.querySelectorAll('.volume-list input[type="checkbox"]')
+ .forEach(c => c.checked = checked);
+};
+
+function runAction(api_key, action, args={}) {
+ windows.list.classList.add('hidden');
+ windows.loading.classList.remove('hidden');
+
+ const volume_ids = [...document.querySelectorAll(
+ '.volume-list input[type="checkbox"]:checked'
+ )].map(v => parseInt(v.parentNode.parentNode.dataset.id))
+
+ fetch(`${url_base}/api/masseditor?api_key=${api_key}`, {
+ 'method': 'POST',
+ 'headers': {'Content-Type': 'application/json'},
+ 'body': JSON.stringify({
+ 'volume_ids': volume_ids,
+ 'action': action,
+ 'args': args
+ })
+ })
+ .then(response => {
+ fillVolumeList(api_key);
+ windows.loading.classList.add('hidden');
+ windows.list.classList.remove('hidden');
+ });
+};
+
+// code run on load
+
+usingApiKey()
+.then(api_key => {
+ fillVolumeList(api_key);
+ addEventListener('.action-bar > div > button', 'click',
+ e => runAction(api_key, e.target.dataset.action)
+ );
+ addEventListener('button[data-action="delete"]', 'click',
+ e => runAction(
+ api_key,
+ e.target.dataset.action,
+ {
+ 'delete_folder': document.querySelector(
+ 'select[name="delete_folder"]'
+ ).value === "true"
+ }
+ )
+ );
+});
+
+addEventListener('#selectall-input', 'change', e => toggleSelection());
diff --git a/frontend/templates/add_volume.html b/frontend/templates/add_volume.html
index 79a393d0..2c0b43e0 100644
--- a/frontend/templates/add_volume.html
+++ b/frontend/templates/add_volume.html
@@ -82,6 +82,7 @@
Volumes
Add Volume
Library Import
+ Mass Editor
Activity
Settings
System
diff --git a/frontend/templates/library_import.html b/frontend/templates/library_import.html
index 5028772c..8950a8a8 100644
--- a/frontend/templates/library_import.html
+++ b/frontend/templates/library_import.html
@@ -62,6 +62,7 @@ Edit ComicVine Match
Volumes
Add Volume
Library Import
+ Mass Editor
Activity
Settings
System
diff --git a/frontend/templates/mass_editor.html b/frontend/templates/mass_editor.html
new file mode 100644
index 00000000..d4ccd929
--- /dev/null
+++ b/frontend/templates/mass_editor.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mass Editor - Kapowarr
+
+
+
+
+
+
Edit ComicVine Match
+
+
+
+
+
+
+
+
+
+ Search Result
+ Action
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading
+
+
+
+
+
+ Delete
+
+ Delete Volume Folder
+ Keep Volume Folder
+
+
+
+
+ Rename
+ Refresh & Scan
+ Auto Search
+ Convert
+
+
+ Unmonitor
+ Monitor
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/templates/view_volume.html b/frontend/templates/view_volume.html
index dabe0d94..08afc92f 100644
--- a/frontend/templates/view_volume.html
+++ b/frontend/templates/view_volume.html
@@ -225,6 +225,7 @@ Convert volume files
Volumes
Add Volume
Library Import
+ Mass Editor
Activity
Settings
System
diff --git a/frontend/templates/volumes.html b/frontend/templates/volumes.html
index 2c58be79..f536b2ab 100644
--- a/frontend/templates/volumes.html
+++ b/frontend/templates/volumes.html
@@ -45,6 +45,7 @@
Volumes
Add Volume
Library Import
+ Mass Editor
Activity
Settings
System
diff --git a/frontend/ui.py b/frontend/ui.py
index 73105cdc..a40422f5 100644
--- a/frontend/ui.py
+++ b/frontend/ui.py
@@ -24,6 +24,10 @@ def ui_add_volume():
def ui_library_import():
return render_template('library_import.html', url_base=ui_vars['url_base'])
+@ui.route('/mass-editor', methods=methods)
+def ui_mass_editor():
+ return render_template('mass_editor.html', url_base=ui_vars['url_base'])
+
@ui.route('/volumes/', methods=methods)
def ui_view_volume(id):
return render_template('view_volume.html', url_base=ui_vars['url_base'])