Add initial project structure with Docker support and chapter downloading functionality
- Created .dockerignore and .gitignore files to exclude unnecessary files from Docker builds. - Added Dockerfile for multi-stage build setup, including dependencies and production environment configuration. - Introduced docker-compose.yml for service orchestration, including FlareSolverr and the main application. - Implemented core functionality for downloading manga chapters, including utilities for handling file paths and chapter metadata. - Added ESLint configuration for code quality and consistency. - Included JSDoc configuration for documentation generation. - Established initial package.json and package-lock.json with necessary dependencies for the project.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
<script>
|
||||
let titles = $state([]);
|
||||
let jobs = $state([]);
|
||||
let chaptersByTitle = $state({});
|
||||
let url = $state("");
|
||||
let error = $state("");
|
||||
let busy = $state(false);
|
||||
let loadingChapters = $state({});
|
||||
let expanded = $state({});
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
let body = options.body;
|
||||
|
||||
if (body !== undefined && body !== null) {
|
||||
if (!headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
} else if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
// Fastify rejects Content-Type: application/json with an empty body
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = "{}";
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || data.message || res.statusText || "Request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [t, j] = await Promise.all([
|
||||
api("/api/titles"),
|
||||
api("/api/jobs?active=1&limit=20"),
|
||||
]);
|
||||
titles = t.titles || [];
|
||||
jobs = j.jobs || [];
|
||||
}
|
||||
|
||||
async function loadChapters(titleId) {
|
||||
loadingChapters = { ...loadingChapters, [titleId]: true };
|
||||
try {
|
||||
const data = await api(`/api/titles/${titleId}/chapters`);
|
||||
chaptersByTitle = { ...chaptersByTitle, [titleId]: data.chapters || [] };
|
||||
} finally {
|
||||
loadingChapters = { ...loadingChapters, [titleId]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(titleId) {
|
||||
const next = !expanded[titleId];
|
||||
expanded = { ...expanded, [titleId]: next };
|
||||
if (next && !chaptersByTitle[titleId]) {
|
||||
await loadChapters(titleId);
|
||||
}
|
||||
}
|
||||
|
||||
async function addTitle(event) {
|
||||
event.preventDefault();
|
||||
error = "";
|
||||
busy = true;
|
||||
try {
|
||||
await api("/api/titles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: url.trim() }),
|
||||
});
|
||||
url = "";
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTitle(id) {
|
||||
error = "";
|
||||
busy = true;
|
||||
try {
|
||||
await api(`/api/titles/${id}/sync`, { method: "POST" });
|
||||
await refresh();
|
||||
if (expanded[id]) {
|
||||
await loadChapters(id);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(title) {
|
||||
error = "";
|
||||
try {
|
||||
await api(`/api/titles/${title.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ enabled: !title.enabled }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTitle(id) {
|
||||
if (!confirm("Remove this title from the watch list?")) {
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
try {
|
||||
const data = await api(`/api/titles/${id}`, { method: "DELETE" });
|
||||
const nextCh = { ...chaptersByTitle };
|
||||
delete nextCh[id];
|
||||
chaptersByTitle = nextCh;
|
||||
if (data.jobs) {
|
||||
jobs = data.jobs;
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function redownload(chapterId, titleId) {
|
||||
error = "";
|
||||
try {
|
||||
await api(`/api/chapters/${chapterId}/redownload`, { method: "POST" });
|
||||
await refresh();
|
||||
await loadChapters(titleId);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelChapter(chapterId, titleId) {
|
||||
error = "";
|
||||
try {
|
||||
await api(`/api/chapters/${chapterId}/cancel`, { method: "POST" });
|
||||
await refresh();
|
||||
if (titleId && expanded[titleId]) {
|
||||
await loadChapters(titleId);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearQueue() {
|
||||
if (!confirm("Clear all waiting download jobs? The current download (if any) will finish.")) {
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
try {
|
||||
const data = await api("/api/jobs/clear", { method: "POST" });
|
||||
jobs = data.jobs || [];
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void refresh();
|
||||
const id = setInterval(() => {
|
||||
void (async () => {
|
||||
await refresh();
|
||||
for (const [titleId, isOpen] of Object.entries(expanded)) {
|
||||
if (isOpen) {
|
||||
await loadChapters(Number(titleId));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="app-shell">
|
||||
<header>
|
||||
<h1>Watch & Download</h1>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="error-banner" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form class="add-title" onsubmit={addTitle}>
|
||||
<label>
|
||||
Series URL
|
||||
<input
|
||||
type="url"
|
||||
bind:value={url}
|
||||
placeholder=""
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !url.trim()}>
|
||||
{busy ? "Working…" : "Add & watch"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section class="jobs-strip" aria-live="polite">
|
||||
<div class="section-heading">
|
||||
<h2>Activity</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="outline contrast"
|
||||
disabled={jobs.length === 0}
|
||||
onclick={clearQueue}
|
||||
>
|
||||
Clear queue
|
||||
</button>
|
||||
</div>
|
||||
{#if jobs.length === 0}
|
||||
<p class="muted">No active jobs.</p>
|
||||
{:else}
|
||||
<ul class="job-list">
|
||||
{#each jobs as job (job.id)}
|
||||
<li class="job-row">
|
||||
<div class="job-info">
|
||||
<strong>{job.status}</strong>
|
||||
— {job.message || job.type}
|
||||
{#if job.title_name}
|
||||
<span class="muted">({job.title_name})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.chapter_id}
|
||||
<button
|
||||
type="button"
|
||||
class="outline contrast job-cancel"
|
||||
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Titles</h2>
|
||||
{#if titles.length === 0}
|
||||
<p class="muted">No titles yet. Add a series URL above.</p>
|
||||
{:else}
|
||||
{#each titles as title (title.id)}
|
||||
<article>
|
||||
<header>
|
||||
<div class="title-meta">
|
||||
<strong>{title.title}</strong>
|
||||
<span class="muted">{title.source}</span>
|
||||
<span class="progress-text">
|
||||
{title.downloaded} / {title.total} downloaded
|
||||
{#if title.downloading > 0}
|
||||
· {title.downloading} downloading
|
||||
{/if}
|
||||
{#if title.failed > 0}
|
||||
· {title.failed} failed
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted url-line">
|
||||
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
||||
{#if title.last_checked_at}
|
||||
· last checked {title.last_checked_at}
|
||||
{/if}
|
||||
· watch {title.enabled ? "on" : "off"}
|
||||
</p>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
|
||||
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
|
||||
</button>
|
||||
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
|
||||
Sync now
|
||||
</button>
|
||||
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
|
||||
{title.enabled ? "Disable watch" : "Enable watch"}
|
||||
</button>
|
||||
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if expanded[title.id]}
|
||||
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
|
||||
<p class="muted">Loading chapters…</p>
|
||||
{:else}
|
||||
<ul class="chapter-list">
|
||||
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
||||
<li class="chapter-card">
|
||||
<div class="chapter-label">{ch.label}</div>
|
||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||
{#if ch.error}
|
||||
<div class="muted chapter-error">{ch.error}</div>
|
||||
{/if}
|
||||
<div class="chapter-actions">
|
||||
{#if ch.status === "downloading"}
|
||||
<button
|
||||
type="button"
|
||||
class="outline contrast"
|
||||
onclick={() => cancelChapter(ch.id, title.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="outline"
|
||||
onclick={() => redownload(ch.id, title.id)}
|
||||
>
|
||||
Redownload
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
||||
<tr>
|
||||
<td>{ch.label}</td>
|
||||
<td>
|
||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||
{#if ch.error}
|
||||
<div class="muted">{ch.error}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="chapter-actions">
|
||||
{#if ch.status === "downloading"}
|
||||
<button
|
||||
type="button"
|
||||
class="outline contrast"
|
||||
onclick={() => cancelChapter(ch.id, title.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="outline"
|
||||
onclick={() => redownload(ch.id, title.id)}
|
||||
>
|
||||
Redownload
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
:root {
|
||||
--pico-font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: clip;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Fluid layout — avoid Pico .container stepped max-widths (510/700/950px) */
|
||||
.app-shell {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: min(960px, 100%);
|
||||
margin-inline: auto;
|
||||
padding-inline: max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px));
|
||||
padding-block: 1.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 5vw, 2rem);
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.url-line,
|
||||
.url-line a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: #a67c00;
|
||||
}
|
||||
|
||||
.status-downloading {
|
||||
color: #0b6bcb;
|
||||
}
|
||||
|
||||
.status-downloaded {
|
||||
color: #1b7f3a;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: #c62828;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jobs-strip {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-heading button {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.jobs-strip ul {
|
||||
margin-bottom: 0;
|
||||
padding-inline-start: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.jobs-strip li {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
.job-info {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-cancel {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.job-cancel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-meta strong {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
article {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
article header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
form.add-title {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
form.add-title label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
form.add-title input {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
form.add-title button {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
margin-bottom: 0;
|
||||
min-width: 28rem;
|
||||
}
|
||||
|
||||
.table-wrap th,
|
||||
.table-wrap td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-wrap td:first-child {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chapter-card {
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
padding: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chapter-card .chapter-label {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.chapter-card .chapter-error {
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chapter-card button {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chapter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chapter-actions button {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.chapter-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.app-shell {
|
||||
padding-inline: max(1.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px));
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
form.add-title {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
form.add-title button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.title-meta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
grid-template-columns: unset;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.table-wrap {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "@picocss/pico/css/pico.min.css";
|
||||
import "./app.css";
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
if (target) {
|
||||
mount(App, { target });
|
||||
}
|
||||
Reference in New Issue
Block a user