Store queue in SQLite with pagination
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-05-11
|
||||||
|
|
||||||
|
- Replaced JSON queue storage with SQLite-backed queue persistence.
|
||||||
|
- Added automatic migration from the previous `queue.json` queue file.
|
||||||
|
- Limited the queue view to 10 jobs per page with previous and next controls.
|
||||||
|
- Added bulk queue actions to remove finished jobs and retry all failed jobs.
|
||||||
|
|
||||||
## 0.1.2 - 2026-05-11
|
## 0.1.2 - 2026-05-11
|
||||||
|
|
||||||
- Updated finalized download layout to save media under `ANI_CLI_DOWNLOAD_DIR/anime_name/Season <season>/`.
|
- Updated finalized download layout to save media under `ANI_CLI_DOWNLOAD_DIR/anime_name/Season <season>/`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A small local web UI for a system-wide `ani-cli` install.
|
A small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.1.2`
|
Current version: `0.2.0`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ http://127.0.0.1:8421/
|
|||||||
|
|
||||||
The app searches anime, queues downloads, supports original or dubbed mode, quality, episode ranges, editable library names, season numbers, and a default download folder.
|
The app searches anime, queues downloads, supports original or dubbed mode, quality, episode ranges, editable library names, season numbers, and a default download folder.
|
||||||
|
|
||||||
|
The queue is stored in SQLite, shown 10 jobs at a time, and includes bulk actions for removing finished jobs and retrying failed jobs.
|
||||||
|
|
||||||
Downloads are staged in the web app state directory first. After `ani-cli` finishes, the wrapper moves them into a Plex/Jellyfin-friendly layout:
|
Downloads are staged in the web app state directory first. After `ani-cli` finishes, the wrapper moves them into a Plex/Jellyfin-friendly layout:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -44,6 +46,6 @@ ANI_CLI_DOWNLOAD_DIR/anime_name/Season <season>/anime_name - S01E01.mp4
|
|||||||
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`.
|
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`.
|
||||||
- `ANI_CLI_WEB_PORT`: server port, default `8421`.
|
- `ANI_CLI_WEB_PORT`: server port, default `8421`.
|
||||||
|
|
||||||
Settings and the queue are stored under XDG config/state directories when writable, with a local `.ani-cli-web/` fallback inside this project.
|
Settings, queue data, and staging files are stored under XDG config/state directories when writable, with a local `.ani-cli-web/` fallback inside this project. Existing `queue.json` data is migrated into SQLite automatically.
|
||||||
|
|
||||||
Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git.
|
Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -20,7 +21,7 @@ from uuid import uuid4
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.1.2"
|
VERSION = "0.2.0"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
@@ -71,6 +72,7 @@ def app_dir(kind):
|
|||||||
|
|
||||||
CONFIG_PATH = app_dir("config") / "config.json"
|
CONFIG_PATH = app_dir("config") / "config.json"
|
||||||
QUEUE_PATH = app_dir("state") / "queue.json"
|
QUEUE_PATH = app_dir("state") / "queue.json"
|
||||||
|
QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3"
|
||||||
STAGING_ROOT = app_dir("state") / "staging"
|
STAGING_ROOT = app_dir("state") / "staging"
|
||||||
|
|
||||||
|
|
||||||
@@ -202,41 +204,114 @@ class DownloadQueue:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.wakeup = threading.Event()
|
self.wakeup = threading.Event()
|
||||||
self.jobs = load_json(QUEUE_PATH, [])
|
|
||||||
self.current_process = None
|
self.current_process = None
|
||||||
self.current_job_id = None
|
self.current_job_id = None
|
||||||
|
self._init_db()
|
||||||
|
self._migrate_json_queue()
|
||||||
self._restore_interrupted_jobs()
|
self._restore_interrupted_jobs()
|
||||||
self.worker = threading.Thread(target=self._run, daemon=True)
|
self.worker = threading.Thread(target=self._run, daemon=True)
|
||||||
self.worker.start()
|
self.worker.start()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
conn = sqlite3.connect(QUEUE_DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
QUEUE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at)")
|
||||||
|
|
||||||
|
def _migrate_json_queue(self):
|
||||||
|
if not QUEUE_PATH.exists():
|
||||||
|
return
|
||||||
|
jobs = load_json(QUEUE_PATH, [])
|
||||||
|
if not jobs:
|
||||||
|
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
||||||
|
return
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
existing = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for job in jobs:
|
||||||
|
if isinstance(job, dict) and job.get("id"):
|
||||||
|
self._upsert_job_conn(conn, job)
|
||||||
|
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
||||||
|
|
||||||
def _restore_interrupted_jobs(self):
|
def _restore_interrupted_jobs(self):
|
||||||
changed = False
|
with self.lock, self._connect() as conn:
|
||||||
for job in self.jobs:
|
rows = conn.execute("SELECT payload FROM jobs WHERE status = 'running'").fetchall()
|
||||||
if job.get("status") == "running":
|
for row in rows:
|
||||||
|
job = self._job_from_row(row)
|
||||||
job["status"] = "pending"
|
job["status"] = "pending"
|
||||||
job["pid"] = None
|
job["pid"] = None
|
||||||
job.setdefault("log", []).append("Restarted after web app restart.")
|
job.setdefault("log", []).append("Restarted after web app restart.")
|
||||||
changed = True
|
self._upsert_job_conn(conn, job)
|
||||||
if changed:
|
|
||||||
self._save_locked()
|
|
||||||
|
|
||||||
def _save_locked(self):
|
def _job_from_row(self, row):
|
||||||
clean_jobs = []
|
payload = row["payload"] if isinstance(row, sqlite3.Row) else row[0]
|
||||||
for job in self.jobs:
|
return json.loads(payload)
|
||||||
clean = dict(job)
|
|
||||||
clean.pop("process", None)
|
|
||||||
clean_jobs.append(clean)
|
|
||||||
write_json(QUEUE_PATH, clean_jobs)
|
|
||||||
|
|
||||||
def list(self):
|
def _upsert_job_conn(self, conn, job):
|
||||||
with self.lock:
|
clean = dict(job)
|
||||||
return [dict(job) for job in self.jobs]
|
clean.pop("process", None)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs (id, status, created_at, updated_at, payload)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
payload = excluded.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
clean["id"],
|
||||||
|
clean.get("status", "pending"),
|
||||||
|
clean.get("created_at") or now_iso(),
|
||||||
|
clean.get("updated_at") or now_iso(),
|
||||||
|
json.dumps(clean, sort_keys=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_job_locked(self, job):
|
||||||
|
with self._connect() as conn:
|
||||||
|
self._upsert_job_conn(conn, job)
|
||||||
|
|
||||||
|
def list(self, page=1, per_page=10):
|
||||||
|
page = max(1, int(page or 1))
|
||||||
|
per_page = min(50, max(1, int(per_page or 10)))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(per_page, offset),
|
||||||
|
).fetchall()
|
||||||
|
pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
return {
|
||||||
|
"jobs": [self._job_from_row(row) for row in rows],
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"pages": pages,
|
||||||
|
}
|
||||||
|
|
||||||
def add(self, payload):
|
def add(self, payload):
|
||||||
job = build_job(payload)
|
job = build_job(payload)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.jobs.append(job)
|
self._save_job_locked(job)
|
||||||
self._save_locked()
|
|
||||||
self.wakeup.set()
|
self.wakeup.set()
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@@ -252,17 +327,36 @@ class DownloadQueue:
|
|||||||
job["finished_at"] = None
|
job["finished_at"] = None
|
||||||
job["updated_at"] = now_iso()
|
job["updated_at"] = now_iso()
|
||||||
job["log"] = []
|
job["log"] = []
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
self.wakeup.set()
|
self.wakeup.set()
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
def retry_all_failed(self):
|
||||||
|
count = 0
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
rows = conn.execute("SELECT payload FROM jobs WHERE status = 'failed'").fetchall()
|
||||||
|
for row in rows:
|
||||||
|
job = self._job_from_row(row)
|
||||||
|
job["status"] = "pending"
|
||||||
|
job["exit_code"] = None
|
||||||
|
job["pid"] = None
|
||||||
|
job["started_at"] = None
|
||||||
|
job["finished_at"] = None
|
||||||
|
job["updated_at"] = now_iso()
|
||||||
|
job["log"] = []
|
||||||
|
self._upsert_job_conn(conn, job)
|
||||||
|
count += 1
|
||||||
|
if count:
|
||||||
|
self.wakeup.set()
|
||||||
|
return {"ok": True, "count": count}
|
||||||
|
|
||||||
def cancel(self, job_id):
|
def cancel(self, job_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
job = self._find(job_id)
|
job = self._find(job_id)
|
||||||
if job["status"] == "pending":
|
if job["status"] == "pending":
|
||||||
job["status"] = "canceled"
|
job["status"] = "canceled"
|
||||||
job["updated_at"] = now_iso()
|
job["updated_at"] = now_iso()
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
return job
|
return job
|
||||||
if job["status"] != "running":
|
if job["status"] != "running":
|
||||||
return job
|
return job
|
||||||
@@ -273,7 +367,7 @@ class DownloadQueue:
|
|||||||
self.current_process.terminate()
|
self.current_process.terminate()
|
||||||
job["cancel_requested"] = True
|
job["cancel_requested"] = True
|
||||||
job["updated_at"] = now_iso()
|
job["updated_at"] = now_iso()
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
return job
|
return job
|
||||||
raise ValueError("Could not find the running process")
|
raise ValueError("Could not find the running process")
|
||||||
|
|
||||||
@@ -282,27 +376,30 @@ class DownloadQueue:
|
|||||||
job = self._find(job_id)
|
job = self._find(job_id)
|
||||||
if job["status"] == "running":
|
if job["status"] == "running":
|
||||||
raise ValueError("Running jobs must be canceled before removing")
|
raise ValueError("Running jobs must be canceled before removing")
|
||||||
self.jobs = [item for item in self.jobs if item["id"] != job_id]
|
with self._connect() as conn:
|
||||||
self._save_locked()
|
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def clear_finished(self):
|
def clear_finished(self):
|
||||||
with self.lock:
|
with self.lock, self._connect() as conn:
|
||||||
self.jobs = [job for job in self.jobs if job.get("status") in {"pending", "running"}]
|
cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')")
|
||||||
self._save_locked()
|
return {"ok": True, "count": cursor.rowcount}
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def _find(self, job_id):
|
def _find(self, job_id):
|
||||||
for job in self.jobs:
|
with self._connect() as conn:
|
||||||
if job.get("id") == job_id:
|
row = conn.execute("SELECT payload FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
return job
|
if row:
|
||||||
|
return self._job_from_row(row)
|
||||||
raise KeyError("Job not found")
|
raise KeyError("Job not found")
|
||||||
|
|
||||||
def _next_pending(self):
|
def _next_pending(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for job in self.jobs:
|
with self._connect() as conn:
|
||||||
if job.get("status") == "pending":
|
row = conn.execute(
|
||||||
return job
|
"SELECT payload FROM jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return self._job_from_row(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _append_log(self, job, line):
|
def _append_log(self, job, line):
|
||||||
@@ -313,7 +410,7 @@ class DownloadQueue:
|
|||||||
job.setdefault("log", []).append(text)
|
job.setdefault("log", []).append(text)
|
||||||
job["log"] = job["log"][-MAX_LOG_LINES:]
|
job["log"] = job["log"][-MAX_LOG_LINES:]
|
||||||
job["updated_at"] = now_iso()
|
job["updated_at"] = now_iso()
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -353,7 +450,7 @@ class DownloadQueue:
|
|||||||
f"Final folder: {job['target_dir']}",
|
f"Final folder: {job['target_dir']}",
|
||||||
]
|
]
|
||||||
job["cancel_requested"] = False
|
job["cancel_requested"] = False
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
@@ -369,7 +466,7 @@ class DownloadQueue:
|
|||||||
self.current_process = process
|
self.current_process = process
|
||||||
self.current_job_id = job["id"]
|
self.current_job_id = job["id"]
|
||||||
job["pid"] = process.pid
|
job["pid"] = process.pid
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
|
|
||||||
assert process.stdout is not None
|
assert process.stdout is not None
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
@@ -405,7 +502,7 @@ class DownloadQueue:
|
|||||||
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
|
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
|
||||||
self.current_process = None
|
self.current_process = None
|
||||||
self.current_job_id = None
|
self.current_job_id = None
|
||||||
self._save_locked()
|
self._save_job_locked(job)
|
||||||
|
|
||||||
|
|
||||||
def strip_control(value):
|
def strip_control(value):
|
||||||
@@ -622,7 +719,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise ValueError("Mode must be sub or dub")
|
raise ValueError("Mode must be sub or dub")
|
||||||
self.json({"episodes": episode_list(show_id, mode)})
|
self.json({"episodes": episode_list(show_id, mode)})
|
||||||
elif parsed.path == "/api/queue":
|
elif parsed.path == "/api/queue":
|
||||||
self.json({"jobs": DOWNLOAD_QUEUE.list()})
|
params = parse_qs(parsed.query)
|
||||||
|
page = (params.get("page") or ["1"])[0]
|
||||||
|
per_page = (params.get("per_page") or ["10"])[0]
|
||||||
|
self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page))
|
||||||
else:
|
else:
|
||||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -637,6 +737,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED)
|
self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED)
|
||||||
elif parsed.path == "/api/queue/clear-finished":
|
elif parsed.path == "/api/queue/clear-finished":
|
||||||
self.json(DOWNLOAD_QUEUE.clear_finished())
|
self.json(DOWNLOAD_QUEUE.clear_finished())
|
||||||
|
elif parsed.path == "/api/queue/retry-failed":
|
||||||
|
self.json(DOWNLOAD_QUEUE.retry_all_failed())
|
||||||
elif parsed.path.startswith("/api/queue/"):
|
elif parsed.path.startswith("/api/queue/"):
|
||||||
parts = parsed.path.strip("/").split("/")
|
parts = parsed.path.strip("/").split("/")
|
||||||
if len(parts) != 4:
|
if len(parts) != 4:
|
||||||
@@ -1032,9 +1134,13 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<section>
|
<section>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>Download queue</h2>
|
<h2>Download queue</h2>
|
||||||
<button class="ghost" id="clearBtn" type="button">Clear finished</button>
|
<div class="row" style="flex:0 0 auto">
|
||||||
|
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
|
||||||
|
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue" id="queue"></div>
|
<div class="queue" id="queue"></div>
|
||||||
|
<div class="toolbar" id="queuePager"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -1044,6 +1150,9 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
config: { mode: "sub", quality: "best", download_dir: "" },
|
config: { mode: "sub", quality: "best", download_dir: "" },
|
||||||
selected: null,
|
selected: null,
|
||||||
episodes: [],
|
episodes: [],
|
||||||
|
queuePage: 1,
|
||||||
|
queuePages: 1,
|
||||||
|
queueTotal: 0,
|
||||||
queueTimer: null
|
queueTimer: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1167,14 +1276,19 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue(jobs) {
|
function renderQueue(data) {
|
||||||
|
const jobs = data.jobs || [];
|
||||||
|
state.queuePage = data.page || 1;
|
||||||
|
state.queuePages = data.pages || 1;
|
||||||
|
state.queueTotal = data.total || 0;
|
||||||
const el = $("queue");
|
const el = $("queue");
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
el.innerHTML = '<div class="empty">Queue is empty.</div>';
|
el.innerHTML = '<div class="empty">Queue is empty.</div>';
|
||||||
|
renderQueuePager();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const job of jobs.slice().reverse()) {
|
for (const job of jobs) {
|
||||||
const item = document.createElement("article");
|
const item = document.createElement("article");
|
||||||
item.className = "job";
|
item.className = "job";
|
||||||
const log = (job.log || []).slice(-28).join("\n");
|
const log = (job.log || []).slice(-28).join("\n");
|
||||||
@@ -1215,6 +1329,29 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
el.appendChild(item);
|
el.appendChild(item);
|
||||||
}
|
}
|
||||||
|
renderQueuePager();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueuePager() {
|
||||||
|
const el = $("queuePager");
|
||||||
|
const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
|
||||||
|
const end = Math.min(state.queuePage * 10, state.queueTotal);
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="muted"></span>
|
||||||
|
<div class="row" style="flex:0 0 auto"></div>
|
||||||
|
`;
|
||||||
|
el.querySelector(".muted").textContent =
|
||||||
|
`${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
|
||||||
|
const controls = el.querySelector(".row");
|
||||||
|
const prev = actionButton("Previous", () => {
|
||||||
|
if (state.queuePage > 1) loadQueue(state.queuePage - 1);
|
||||||
|
});
|
||||||
|
const next = actionButton("Next", () => {
|
||||||
|
if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
|
||||||
|
});
|
||||||
|
prev.disabled = state.queuePage <= 1;
|
||||||
|
next.disabled = state.queuePage >= state.queuePages;
|
||||||
|
controls.append(prev, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionButton(text, handler, extra = "") {
|
function actionButton(text, handler, extra = "") {
|
||||||
@@ -1229,15 +1366,26 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
async function queueAction(id, action) {
|
async function queueAction(id, action) {
|
||||||
try {
|
try {
|
||||||
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
|
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
|
||||||
await loadQueue();
|
await loadQueue(state.queuePage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error.message);
|
setNotice(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQueue() {
|
async function queueBulkAction(path, message) {
|
||||||
const data = await api("/api/queue");
|
try {
|
||||||
renderQueue(data.jobs);
|
const data = await api(path, { method: "POST", body: "{}" });
|
||||||
|
setNotice(`${message}: ${data.count || 0}`);
|
||||||
|
await loadQueue(state.queuePage);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueue(page = state.queuePage || 1) {
|
||||||
|
const data = await api(`/api/queue?page=${page}&per_page=10`);
|
||||||
|
if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
|
||||||
|
renderQueue(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
@@ -1289,10 +1437,8 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
});
|
});
|
||||||
$("addBtn").addEventListener("click", addSelected);
|
$("addBtn").addEventListener("click", addSelected);
|
||||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||||
$("clearBtn").addEventListener("click", async () => {
|
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
|
||||||
await api("/api/queue/clear-finished", { method: "POST", body: "{}" });
|
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
||||||
await loadQueue();
|
|
||||||
});
|
|
||||||
$("quality").addEventListener("change", () => { state.config.quality = $("quality").value; });
|
$("quality").addEventListener("change", () => { state.config.quality = $("quality").value; });
|
||||||
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
|
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
|
||||||
$("latestEpBtn").addEventListener("click", () => {
|
$("latestEpBtn").addEventListener("click", () => {
|
||||||
@@ -1340,7 +1486,7 @@ def main():
|
|||||||
install_signal_handlers(httpd)
|
install_signal_handlers(httpd)
|
||||||
print(f"ani-cli web is running at http://{host}:{port}/")
|
print(f"ani-cli web is running at http://{host}:{port}/")
|
||||||
print(f"Config: {CONFIG_PATH}")
|
print(f"Config: {CONFIG_PATH}")
|
||||||
print(f"Queue: {QUEUE_PATH}")
|
print(f"Queue: {QUEUE_DB_PATH}")
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user