diff --git a/docker-compose.yml b/docker-compose.yml index e9522a6..ff1a3e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,4 @@ services: - flaresolverr: - image: ghcr.io/flaresolverr/flaresolverr:latest - environment: - LOG_LEVEL: info - ports: - - "8191:8191" - restart: unless-stopped - cxyz: build: . ports: @@ -18,7 +10,6 @@ services: WATCH_CRON: "0 * * * *" # Each title is eligible again after this many hours (daily). WATCH_TITLE_INTERVAL_HOURS: "24" - FLARESOLVERR_URL: http://flaresolverr:8191/v1 # Shared login (optional). When AUTH_PASSWORD is set, the UI and /api require a session. # AUTH_USERNAME: admin # AUTH_PASSWORD: change-me @@ -26,6 +17,4 @@ services: # SESSION_TTL_HOURS: "168" volumes: - ./data:/app/data - depends_on: - - flaresolverr restart: unless-stopped diff --git a/web/src/App.svelte b/web/src/App.svelte index ff3343c..5328ccf 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,8 +1,15 @@
-
-

Watch & Download

- {#if authReady && authRequired && authenticated} -
- {#if authUsername} - {authUsername} - {/if} - -
- {/if} -
+ {#if !authReady}

Loading…

{:else if authRequired && !authenticated} - {#if loginError} - - {/if} - + {:else} - {#if error} - - {/if} - -
- - -
- -
-
-

Activity

- -
- {#if jobs.length === 0} -

No active jobs.

- {:else} -
    - {#each jobs as job (job.id)} -
  • -
    - {job.status} - — {job.message || job.type} - {#if job.title_name} - ({job.title_name}) - {/if} -
    - {#if job.chapter_id} - - {/if} -
  • - {/each} -
- {/if} -
- -
-

Titles

- {#if titles.length === 0} -

No titles yet. Add a series URL above.

- {:else} - {#each titles as title (title.id)} -
-
-
- {title.title} - {title.source} - - {title.downloaded} / {title.total} downloaded - {#if title.downloading > 0} - · {title.downloading} downloading - {/if} - {#if title.failed > 0} - · {title.failed} failed - {/if} - -
-

- {title.url} - {#if title.last_checked_at} - · last checked {title.last_checked_at} - {/if} - · watch {title.enabled ? "on" : "off"} -

-
- - - - -
-
- - {#if expanded[title.id]} - {#if loadingChapters[title.id] && !chaptersByTitle[title.id]} -

Loading chapters…

- {:else} -
    - {#each chaptersByTitle[title.id] || [] as ch (ch.id)} -
  • -
    {ch.label}
    - {ch.status} - {#if ch.error} -
    {ch.error}
    - {/if} -
    - {#if ch.status === "downloading"} - - {/if} - -
    -
  • - {/each} -
-
- - - - - - - - - - {#each chaptersByTitle[title.id] || [] as ch (ch.id)} - - - - - - {/each} - -
ChapterStatus
{ch.label} - {ch.status} - {#if ch.error} -
{ch.error}
- {/if} -
-
- {#if ch.status === "downloading"} - - {/if} - -
-
-
- {/if} - {/if} -
- {/each} - {/if} -
+ + + + {/if}
diff --git a/web/src/components/AddTitleForm.svelte b/web/src/components/AddTitleForm.svelte new file mode 100644 index 0000000..70efffa --- /dev/null +++ b/web/src/components/AddTitleForm.svelte @@ -0,0 +1,32 @@ + + +
+ + +
diff --git a/web/src/components/AppHeader.svelte b/web/src/components/AppHeader.svelte new file mode 100644 index 0000000..e81b9b5 --- /dev/null +++ b/web/src/components/AppHeader.svelte @@ -0,0 +1,23 @@ + + +
+

Watch & Download

+ {#if showLogout} +
+ {#if username} + {username} + {/if} + +
+ {/if} +
diff --git a/web/src/components/ChapterList.svelte b/web/src/components/ChapterList.svelte new file mode 100644 index 0000000..a4940eb --- /dev/null +++ b/web/src/components/ChapterList.svelte @@ -0,0 +1,87 @@ + + + + +
+ + + + + + + + + + {#each chapters as ch (ch.id)} + + + + + + {/each} + +
ChapterStatus
{ch.label} + {ch.status} + {#if ch.error} +
{ch.error}
+ {/if} +
+
+ {#if ch.status === "downloading"} + + {/if} + +
+
+
diff --git a/web/src/components/ErrorBanner.svelte b/web/src/components/ErrorBanner.svelte new file mode 100644 index 0000000..146624e --- /dev/null +++ b/web/src/components/ErrorBanner.svelte @@ -0,0 +1,13 @@ + + +{#if message} + +{/if} diff --git a/web/src/components/JobsStrip.svelte b/web/src/components/JobsStrip.svelte new file mode 100644 index 0000000..33a5be4 --- /dev/null +++ b/web/src/components/JobsStrip.svelte @@ -0,0 +1,62 @@ + + +
+
+

Activity

+ +
+ {#if jobs.length === 0} +

No active jobs.

+ {:else} + + {/if} +
diff --git a/web/src/components/LoginForm.svelte b/web/src/components/LoginForm.svelte new file mode 100644 index 0000000..e42c99b --- /dev/null +++ b/web/src/components/LoginForm.svelte @@ -0,0 +1,50 @@ + + +{#if error} + +{/if} + +
+ + + +
diff --git a/web/src/components/TitleCard.svelte b/web/src/components/TitleCard.svelte new file mode 100644 index 0000000..aff46da --- /dev/null +++ b/web/src/components/TitleCard.svelte @@ -0,0 +1,99 @@ + + +
+
+
+ {title.title} + {title.source} + + {title.downloaded} / {title.total} downloaded + {#if title.downloading > 0} + · {title.downloading} downloading + {/if} + {#if title.failed > 0} + · {title.failed} failed + {/if} + +
+

+ {title.url} + {#if title.last_checked_at} + · last checked {title.last_checked_at} + {/if} + · watch {title.enabled ? "on" : "off"} +

+
+ + + + +
+
+ + {#if expanded} + {#if loading && chapters.length === 0} +

Loading chapters…

+ {:else} + + {/if} + {/if} +
diff --git a/web/src/components/TitleList.svelte b/web/src/components/TitleList.svelte new file mode 100644 index 0000000..6641ffc --- /dev/null +++ b/web/src/components/TitleList.svelte @@ -0,0 +1,56 @@ + + +
+

Titles

+ {#if titles.length === 0} +

No titles yet. Add a series URL above.

+ {:else} + {#each titles as title (title.id)} + onToggleExpand(title.id)} + onSync={() => onSync(title.id)} + onToggleEnabled={() => onToggleEnabled(title)} + onRemove={() => onRemove(title.id)} + {onCancelChapter} + {onRedownload} + /> + {/each} + {/if} +
diff --git a/web/src/lib/api.js b/web/src/lib/api.js new file mode 100644 index 0000000..53fa3b6 --- /dev/null +++ b/web/src/lib/api.js @@ -0,0 +1,37 @@ +/** + * @param {string} path - API path. + * @param {RequestInit & { onUnauthorized?: () => void }} [options] - Fetch options. + * @returns {Promise} Parsed JSON body. + */ +export async function api(path, options = {}) { + const { onUnauthorized, ...fetchOptions } = options; + const method = (fetchOptions.method || "GET").toUpperCase(); + const headers = { ...(fetchOptions.headers || {}) }; + let body = fetchOptions.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, { + ...fetchOptions, + method, + headers, + body, + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (res.status === 401 && typeof onUnauthorized === "function") { + onUnauthorized(); + } + if (!res.ok) { + throw new Error(data.error || data.message || res.statusText || "Request failed"); + } + return data; +}