Compare commits

...

6 Commits

Author SHA1 Message Date
Admin
809dc8d898 fix: make asynq consumer actually claim and heartbeat translation tasks
All checks were successful
Release / Check ui (push) Successful in 27s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 1m3s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / runner (push) Successful in 3m23s
Release / Docker / backend (push) Successful in 4m26s
Release / Gitea Release (push) Successful in 13s
ClaimNextTranslationTask and HeartbeatTask were no-ops in the asynq
Consumer, so translation tasks created in PocketBase were never picked
up by the runner. Translation tasks live in PocketBase (not Redis),
so they must be claimed/heartbeated via the underlying pb consumer.
ReapStaleTasks is also delegated so stale translation tasks get reset.

Also removes the LibreTranslate healthcheck from homelab/runner
docker-compose.yml and relaxes depends_on to service_started — the
healthcheck was blocking runner startup until models loaded (~2 min)
and the models are already pre-downloaded in the volume.
2026-04-02 21:16:48 +05:00
Admin
e9c3426fbe feat: scroll active chapter into view when chapter drawer opens
All checks were successful
Release / Check ui (push) Successful in 40s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 3m26s
Release / Docker / backend (push) Successful in 4m3s
Release / Gitea Release (push) Successful in 12s
When the mini-player chapter drawer is opened, the current chapter is
now immediately scrolled into the center of the list instead of always
starting from the top. Uses a Svelte action (setIfActive) to track the
active chapter element and a $effect to call scrollIntoView on open.
2026-04-02 20:44:12 +05:00
Admin
8e611840d1 fix: add 30s timeout to PB HTTP client; halve heartbeat tick interval
All checks were successful
Release / Test backend (push) Successful in 32s
Release / Docker / caddy (push) Successful in 42s
Release / Check ui (push) Successful in 44s
Release / Docker / ui (push) Successful in 2m32s
Release / Docker / backend (push) Successful in 2m49s
Release / Docker / runner (push) Successful in 3m26s
Release / Gitea Release (push) Successful in 15s
- storage/pocketbase.go: replace http.DefaultClient (no timeout) with a
  dedicated pbHTTPClient{Timeout: 30s} so a slow/hung PocketBase cannot
  stall the backend or runner indefinitely
- runner/asynq_runner.go: heartbeat ticker was firing at StaleTaskThreshold
  (2 min) == the Docker healthcheck deadline, so a single missed tick would
  mark the container unhealthy; halved to StaleTaskThreshold/2 (1 min)
2026-04-02 18:49:04 +05:00
Admin
b9383570e3 ci: fix duplicate runs — ignore tag pushes and remove pull_request trigger
All checks were successful
Release / Test backend (push) Successful in 24s
Release / Docker / caddy (push) Successful in 50s
Release / Check ui (push) Successful in 57s
Release / Docker / backend (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m47s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 13s
2026-04-02 18:00:48 +05:00
Admin
eac9358c6f fix(discover): guard active card with {#if currentBook} to fix TS errors; use $state for cardEl bind
All checks were successful
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 26s
Release / Test backend (push) Successful in 23s
CI / UI (push) Successful in 39s
CI / Backend (push) Successful in 42s
Release / Check ui (push) Successful in 28s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m12s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / backend (push) Successful in 3m16s
Release / Gitea Release (push) Successful in 13s
2026-04-02 17:48:25 +05:00
Admin
9cb11bc5e4 chore(pb): add discovery_votes collection to pb-init script
Some checks failed
CI / UI (pull_request) Failing after 30s
CI / Backend (pull_request) Successful in 34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:38:15 +05:00
8 changed files with 73 additions and 39 deletions

View File

@@ -2,11 +2,8 @@ name: CI
on:
push:
paths:
- "backend/**"
- "ui/**"
- ".gitea/workflows/ci.yaml"
pull_request:
tags-ignore:
- "v*"
paths:
- "backend/**"
- "ui/**"

View File

@@ -10,14 +10,13 @@ import (
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
//
// When using Asynq, the runner no longer polls for work — Asynq delivers
// tasks via the ServeMux handlers. The only Consumer operations the handlers
// need are:
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
// - FailTask — mark PocketBase record as failed
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
// delivers those tasks via the ServeMux handlers. However translation tasks
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
// still delegate to the underlying PocketBase consumer.
//
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
// are all no-ops here because Asynq owns those responsibilities.
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
// those responsibilities.
type Consumer struct {
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
}
@@ -55,10 +54,18 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
return domain.AudioTask{}, false, nil
}
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
// to pending and can be reclaimed.
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
return c.pb.ReapStaleTasks(ctx, staleAfter)
}

View File

@@ -78,7 +78,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
// This mirrors the heartbeat file behavior from the poll() loop.
go func() {
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
defer heartbeatTick.Stop()
for {
select {

View File

@@ -26,6 +26,11 @@ import (
// ErrNotFound is returned by single-record lookups when no record exists.
var ErrNotFound = errors.New("storage: record not found")
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
// hung PocketBase never stalls the backend/runner process indefinitely.
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
// pbClient is the internal PocketBase REST admin client.
type pbClient struct {
baseURL string
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("pb auth: %w", err)
}
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
}

View File

@@ -28,20 +28,13 @@ services:
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/languages'); sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
depends_on:
libretranslate:
condition: service_healthy
- libretranslate
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"

View File

@@ -259,6 +259,14 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "discovery_votes" '{
"name":"discovery_votes","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"slug", "type":"text","required":true},
{"name":"action", "type":"text","required":true}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"

View File

@@ -33,6 +33,21 @@
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
let activeChapterEl = $state<HTMLElement | null>(null);
function setIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) activeChapterEl = node;
return {
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
};
}
$effect(() => {
if (chapterDrawerOpen && activeChapterEl) {
activeChapterEl.scrollIntoView({ block: 'center' });
}
});
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
@@ -314,15 +329,6 @@
>
{m.nav_catalogue()}
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
>
{m.nav_feedback()}
</a>
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -423,6 +429,18 @@
{m.nav_admin_panel()}
</a>
{/if}
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
onclick={() => { userMenuOpen = false; }}
class="flex items-center justify-between gap-2 px-3 py-2 text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
{m.nav_feedback()}
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
@@ -503,9 +521,12 @@
target="_blank"
rel="noopener noreferrer"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text) flex items-center justify-between"
>
{m.nav_feedback()}
{m.nav_feedback()}
<svg class="w-3.5 h-3.5 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="/profile"
@@ -678,6 +699,7 @@
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
use:setIfActive={ch.number === audioStore.chapter}
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter

View File

@@ -114,7 +114,7 @@
);
const rotation = $derived(isDragging ? Math.max(-18, Math.min(18, offsetX / 12)) : 0);
let cardEl: HTMLDivElement | null = null;
let cardEl = $state<HTMLDivElement | null>(null);
function onPointerDown(e: PointerEvent) {
if (animating || !currentBook) return;
@@ -446,6 +446,7 @@
{/if}
<!-- Active card -->
{#if currentBook}
<div
bind:this={cardEl}
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
@@ -522,6 +523,7 @@
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
</div>
</div>
{/if}
</div>
<!-- Action buttons -->