Compare commits

..

2 Commits

Author SHA1 Message Date
MechaCat02
ee070d8878 feat: cover retry backfill + admin force-resync for manga & chapter (0.50.0)
Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose
cover download failed on first attempt get retried — the metadata pass's
early-stop optimisation otherwise prevents the walk from revisiting them.

Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync
that refetch metadata + cover (or chapter content with force_refetch) from the
crawler source synchronously and return the refreshed row. Surfaced in the
UI as "Force resync" buttons on the manga detail and reader pages,
admin-only via session.user.is_admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:51:46 +02:00
MechaCat02
5c22dfdb41 feat: paginate list views, fix stale page titles, tidy admin filter bar
Bundle of small UI/UX fixes plus a build hygiene tweak.

* List pagination — Home (`/`) and `/authors/[id]` silently capped at
  the backend default of 50 with no UI to advance. New reusable
  `Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced
  `?page=N`, and filter/search/sort reset to page 1 so users aren't
  stranded on an out-of-range page. Count label now shows a range
  ("Showing 51–100 of 237").

* Stale page title — Pages without a `<svelte:head><title>` left the
  document title at whatever the last manga / author / collection page
  set it to. Move static-route titles into a route-id → title map in
  the root layout and invert every dynamic title to brand-first
  (`Mangalord | {X}`) for consistency.

* Admin filter bar — `/admin/mangas` search input had `flex: 1` and
  ballooned across the row, shoving the sync-state select + Search
  button to the far right. Cap at 24rem, vertical-align the row, and
  promote the previously aria-only "Sync state" label to visible text.

* Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and
  added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]`
  too) to cut future dev builds by ~50–70% while keeping line numbers
  in backtraces.

Also: configure vitest to resolve Svelte's browser entry so
`@testing-library/svelte` can mount components in jsdom — needed for
the new `Pager.svelte.test.ts`.

Bump 0.48.0 -> 0.49.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:18:53 +02:00
36 changed files with 2003 additions and 67 deletions

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.48.0"
version = "0.50.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.48.0"
version = "0.50.0"
edition = "2021"
default-run = "mangalord"
@@ -57,3 +57,13 @@ http-body-util = "0.1"
mime = "0.3"
futures-util = "0.3"
tokio = { version = "1", features = ["test-util"] }
# Trim debug builds: keep line numbers in panics / backtraces but drop the
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
# axum + tokio dep tree the default ("full") leaves backend/target on the
# order of tens of GiB; this typically cuts ~5070% off that.
[profile.dev]
debug = "line-tables-only"
[profile.test]
debug = "line-tables-only"

View File

@@ -5,6 +5,7 @@
//! `crate::auth::extractor::RequireAdmin`).
pub mod mangas;
pub mod resync;
pub mod system;
pub mod users;
@@ -16,5 +17,6 @@ pub fn routes() -> Router<AppState> {
Router::new()
.merge(users::routes())
.merge(mangas::routes())
.merge(resync::routes())
.merge(system::routes())
}

View File

@@ -0,0 +1,176 @@
//! Admin-triggered force resync of a single manga's metadata + cover,
//! or a single chapter's content.
//!
//! Both endpoints are admin-only (`RequireAdmin`, cookie-only) and run
//! synchronously with the request — the response carries the refreshed
//! resource so the UI can swap it in without a follow-up GET. The work
//! itself is delegated to [`ResyncService`] (set on AppState by
//! `app::build` when the crawler daemon is enabled); when the daemon
//! is disabled, both handlers return 503.
use axum::extract::{Path, State};
use axum::routing::post;
use axum::{Json, Router};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::crawler::resync::{ChapterResyncOutcome, ResyncError};
use crate::domain::manga::MangaDetail;
use crate::domain::Chapter;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::crawler::UpsertStatus;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/mangas/:id/resync", post(resync_manga))
.route("/admin/chapters/:id/resync", post(resync_chapter))
}
#[derive(Debug, Serialize)]
pub struct MangaResyncResponse {
pub manga: MangaDetail,
/// `"new" | "updated" | "unchanged"` — mirrors [`UpsertStatus`].
pub metadata_status: &'static str,
pub cover_fetched: bool,
}
#[derive(Debug, Serialize)]
pub struct ChapterResyncResponse {
pub chapter: Chapter,
/// `"fetched" | "skipped"` — whether new pages landed or the
/// service short-circuited (e.g. chapter already had pages and the
/// session was lost so force was downgraded).
pub outcome: &'static str,
/// Page count when `outcome == "fetched"`. `None` for `skipped`.
pub pages: Option<usize>,
}
async fn resync_manga(
State(state): State<AppState>,
admin: RequireAdmin,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaResyncResponse>> {
if !repo::manga::exists(&state.db, manga_id).await? {
return Err(AppError::NotFound);
}
let resync = state
.resync
.as_ref()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;
let outcome = resync.resync_manga(manga_id).await.map_err(map_resync_err)?;
// Audit the action with the actor + the resync outcome so an
// operator-of-operators can answer "who refetched this manga, and
// did the cover land?" from the log alone.
repo::admin_audit::insert(
&state.db,
admin.0.id,
"manga_resync",
"manga",
Some(manga_id),
json!({
"metadata_status": status_str(outcome.metadata_status),
"cover_fetched": outcome.cover_fetched,
}),
)
.await?;
let manga = repo::manga::get_detail(&state.db, manga_id).await?;
Ok(Json(MangaResyncResponse {
manga,
metadata_status: status_str(outcome.metadata_status),
cover_fetched: outcome.cover_fetched,
}))
}
async fn resync_chapter(
State(state): State<AppState>,
admin: RequireAdmin,
Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ChapterResyncResponse>> {
let resync = state
.resync
.as_ref()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;
// Look up the manga the chapter belongs to so we can return the
// refreshed chapter row in the response and 404 for unknown ids.
let manga_id: Option<Uuid> =
sqlx::query_scalar("SELECT manga_id FROM chapters WHERE id = $1")
.bind(chapter_id)
.fetch_optional(&state.db)
.await?;
let Some(manga_id) = manga_id else {
return Err(AppError::NotFound);
};
let outcome = resync
.resync_chapter(chapter_id)
.await
.map_err(map_resync_err)?;
let (outcome_str, pages) = match &outcome {
ChapterResyncOutcome::Fetched { pages, .. } => ("fetched", Some(*pages)),
ChapterResyncOutcome::Skipped { .. } => ("skipped", None),
};
repo::admin_audit::insert(
&state.db,
admin.0.id,
"chapter_resync",
"chapter",
Some(chapter_id),
json!({
"outcome": outcome_str,
"pages": pages,
}),
)
.await?;
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(ChapterResyncResponse {
chapter,
outcome: outcome_str,
pages,
}))
}
fn status_str(s: UpsertStatus) -> &'static str {
match s {
UpsertStatus::New => "new",
UpsertStatus::Updated => "updated",
UpsertStatus::Unchanged => "unchanged",
}
}
/// Map [`ResyncError`] (and the anyhow envelopes wrapping it) onto the
/// right [`AppError`]. Anything else surfaces as a generic 500 via the
/// `Other` arm — the operator sees the underlying anyhow chain in
/// server logs, the client sees a clean envelope.
fn map_resync_err(err: anyhow::Error) -> AppError {
if let Some(rerr) = err.downcast_ref::<ResyncError>() {
match rerr {
ResyncError::NoMangaSource => AppError::ValidationFailed {
message: "manga has no live crawler source — cannot resync".into(),
details: json!({ "manga": "no_source" }),
},
ResyncError::NoChapterSource => AppError::ValidationFailed {
message: "chapter has no live crawler source — cannot resync".into(),
details: json!({ "chapter": "no_source" }),
},
}
} else {
AppError::Other(err)
}
}

View File

@@ -24,6 +24,7 @@ use crate::crawler::daemon::{self, ChapterDispatcher, DaemonConfig, MetadataPass
use crate::crawler::jobs::JobPayload;
use crate::crawler::pipeline::{self, MetadataStats};
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::resync::{RealResyncService, ResyncService};
use crate::crawler::safety::DownloadAllowlist;
use crate::crawler::session;
use crate::repo;
@@ -39,6 +40,12 @@ pub struct AppState {
/// One instance per AppState so tests stay isolated across the
/// same process.
pub auth_limiter: Arc<AuthRateLimiter>,
/// Admin-triggered force resync. `None` when the crawler daemon
/// is disabled (`CRAWLER_DAEMON=false`); admin handlers gate on
/// `.is_some()` and return 503 otherwise. Set by [`build`] from the
/// same wiring that builds the daemon's chapter dispatcher, so a
/// force resync uses the daemon's BrowserManager + rate limiters.
pub resync: Option<Arc<dyn ResyncService>>,
}
/// Bundle returned by [`build`]. The router is what `axum::serve` consumes;
@@ -73,11 +80,12 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
let daemon = if config.crawler.daemon_enabled {
Some(spawn_crawler_daemon(db.clone(), Arc::clone(&storage), &config.crawler).await?)
let (daemon, resync) = if config.crawler.daemon_enabled {
let spawned = spawn_crawler_daemon(db.clone(), Arc::clone(&storage), &config.crawler).await?;
(Some(spawned.handle), Some(spawned.resync))
} else {
tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)");
None
(None, None)
};
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
@@ -87,16 +95,26 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
auth: config.auth.clone(),
upload: config.upload.clone(),
auth_limiter,
resync,
};
let router = router(state).layer(cors_layer(&config.cors_allowed_origins));
Ok(AppHandle { router, daemon })
}
/// Bundle returned by [`spawn_crawler_daemon`]. The handle owns the
/// daemon's tasks; `resync` is the operator-trigger service shared with
/// `AppState` so admin endpoints can call into the same browser /
/// rate-limit machinery.
struct SpawnedDaemon {
handle: daemon::DaemonHandle,
resync: Arc<dyn ResyncService>,
}
async fn spawn_crawler_daemon(
db: PgPool,
storage: Arc<dyn Storage>,
cfg: &CrawlerConfig,
) -> anyhow::Result<daemon::DaemonHandle> {
) -> anyhow::Result<SpawnedDaemon> {
// Reqwest client with cookie jar pre-seeded so CDN image fetches
// include PHPSESSID. Same shape as bin/crawler.rs main().
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
@@ -198,6 +216,17 @@ async fn spawn_crawler_daemon(
});
let dispatcher: Arc<dyn ChapterDispatcher> = Arc::new(RealChapterDispatcher {
browser_manager: Arc::clone(&browser_manager),
db: db.clone(),
storage: Arc::clone(&storage),
http: http.clone(),
rate: Arc::clone(&rate),
download_allowlist: cfg.download_allowlist.clone(),
max_image_bytes: cfg.max_image_bytes,
tor: tor.as_ref().map(Arc::clone),
});
let resync: Arc<dyn ResyncService> = Arc::new(RealResyncService {
browser_manager: Arc::clone(&browser_manager),
db: db.clone(),
storage: Arc::clone(&storage),
@@ -242,7 +271,10 @@ async fn spawn_crawler_daemon(
},
);
Ok(daemon_handle)
Ok(SpawnedDaemon {
handle: daemon_handle,
resync,
})
}
// Real impls of the daemon traits, owning the browser manager + I/O. Kept
@@ -285,6 +317,36 @@ impl MetadataPass for RealMetadataPass {
self.browser_manager.invalidate().await;
}
}
// Cover backfill follows the metadata pass even when the pass
// errored — the early-stop walk can complete its work and bail
// late, and a transient browser failure shouldn't cancel the
// residual cover backlog. The backfill has its own per-call cap
// so a runaway error stream can't monopolise the tick.
match pipeline::backfill_missing_covers(
&self.browser_manager,
&self.db,
self.storage.as_ref(),
&self.http,
&self.rate,
pipeline::COVER_BACKFILL_DEFAULT_MAX,
&self.download_allowlist,
self.max_image_bytes,
self.tor.as_deref(),
)
.await
{
Ok(stats) => {
if stats.considered > 0 {
tracing::info!(?stats, "cover backfill complete");
}
}
Err(e) => {
tracing::warn!(error = ?e, "cover backfill failed");
if crate::crawler::nav::anyhow_looks_browser_dead(&e) {
self.browser_manager.invalidate().await;
}
}
}
result
}
}

View File

@@ -23,6 +23,7 @@ pub mod jobs;
pub mod nav;
pub mod pipeline;
pub mod rate_limit;
pub mod resync;
pub mod safety;
pub mod session;
pub mod source;

View File

@@ -13,7 +13,7 @@ use crate::crawler::jobs::{self, EnqueueResult, JobPayload};
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::safety::{fetch_bytes_capped, looks_like_image, DownloadAllowlist};
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source};
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::repo;
use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage;
@@ -523,12 +523,133 @@ pub struct EnqueueSummary {
pub failed: usize,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CoverBackfillStats {
pub considered: usize,
pub fetched: usize,
pub failed: usize,
}
/// Default per-tick cap for [`backfill_missing_covers`]. The metadata pass
/// already retries covers when its walk reaches the affected manga; this
/// backfill exists to catch the residual case where the early-stop
/// optimisation prevents the walk from reaching mangas whose cover failed
/// on first attempt. A small cap is enough because the backlog only grows
/// from sporadic download failures, not from systematic misses.
pub const COVER_BACKFILL_DEFAULT_MAX: usize = 10;
/// Re-attempt cover downloads for mangas where `cover_image_path IS NULL`
/// but a live `manga_sources` row exists. Refetches the source detail
/// page (which is where the cover URL lives) and downloads the cover.
///
/// Bounded by `max_mangas` per call so a steady stream of failing covers
/// — e.g. a CDN host that's persistently 502 — can't monopolise a cron
/// tick. Orders by `manga_sources.last_seen_at DESC` so the freshest
/// missing-cover mangas are addressed first.
///
/// Failures are logged and counted, not raised: a single bad cover URL
/// must not stall every other backfill behind it.
#[allow(clippy::too_many_arguments)]
pub async fn backfill_missing_covers(
browser_manager: &BrowserManager,
db: &PgPool,
storage: &dyn Storage,
http: &reqwest::Client,
rate: &HostRateLimiters,
max_mangas: usize,
allowlist: &DownloadAllowlist,
max_image_bytes: usize,
tor: Option<&crate::crawler::tor::TorController>,
) -> anyhow::Result<CoverBackfillStats> {
let mut stats = CoverBackfillStats::default();
if max_mangas == 0 {
return Ok(stats);
}
let entries = repo::crawler::list_missing_covers(db, max_mangas as i64)
.await
.context("list_missing_covers")?;
if entries.is_empty() {
return Ok(stats);
}
let lease = browser_manager
.acquire()
.await
.context("acquire browser lease for cover backfill")?;
let browser_ref: &chromiumoxide::Browser = &lease;
let ctx = FetchContext { browser: browser_ref, rate, tor };
for entry in entries {
stats.considered += 1;
// Metadata-only TargetSource: skip chapter-list parsing so a
// missing-cover refetch doesn't soft-drop chapters on a partial
// render. Cover URL alone is what we need.
let source = TargetSource::new(entry.source_url.clone()).without_chapter_parsing();
let r = SourceMangaRef {
source_manga_key: entry.source_manga_key.clone(),
title: String::new(),
url: entry.source_url.clone(),
};
let cover_url = match source.fetch_manga(&ctx, &r).await {
Ok(manga) => manga.cover_url,
Err(e) => {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
error = ?e,
"cover backfill: fetch_manga failed"
);
stats.failed += 1;
continue;
}
};
let Some(cover_url) = cover_url else {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
"cover backfill: source returned no cover_url"
);
stats.failed += 1;
continue;
};
match download_and_store_cover(
db,
storage,
http,
rate,
&entry.source_url,
entry.manga_id,
&cover_url,
allowlist,
max_image_bytes,
)
.await
{
Ok(()) => stats.fetched += 1,
Err(e) => {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
error = ?e,
"cover backfill: download failed"
);
stats.failed += 1;
}
}
}
drop(lease);
Ok(stats)
}
/// Download a cover image and persist its storage path. Local to the
/// pipeline because the CLI still calls it from its inline chapter-content
/// loop; once the worker pool fully replaces that path we can fold this
/// into `pipeline` proper.
#[allow(clippy::too_many_arguments)]
async fn download_and_store_cover(
pub(crate) async fn download_and_store_cover(
db: &PgPool,
storage: &dyn Storage,
http: &reqwest::Client,

View File

@@ -0,0 +1,277 @@
//! Admin-triggered resync of a single manga's metadata + cover, or a
//! single chapter's content.
//!
//! The cron tick already retries covers and chapter content on its own
//! schedule. This module exists for the operator-controlled path:
//! "this manga's metadata is stale / its cover never landed / this
//! chapter is broken — pull from source now, not at the next daily
//! tick." Wired into the admin API, never into the queue, so the work
//! happens synchronously with the HTTP request and the admin sees the
//! refreshed row in the response.
//!
//! Shares the daemon's [`BrowserManager`], rate limiter, HTTP client,
//! and TOR controller so a force resync respects the same per-host
//! pacing and recircuit budget the daily crawl uses — admin actions
//! must not let an operator accidentally hammer the source.
use std::sync::Arc;
use anyhow::Context;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crawler::browser_manager::BrowserManager;
use crate::crawler::content::{self, SyncOutcome};
use crate::crawler::pipeline;
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::safety::DownloadAllowlist;
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::crawler::tor::TorController;
use crate::repo;
use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage;
/// Outcome of [`ResyncService::resync_manga`]. Mirrors the bits the
/// admin UI cares about — was the row actually re-upserted, did the
/// cover land — so the response can show "metadata refreshed, cover
/// re-downloaded" or "metadata unchanged" without a second round-trip.
#[derive(Debug, Clone, Copy)]
pub struct MangaResyncOutcome {
pub manga_id: Uuid,
pub metadata_status: UpsertStatus,
pub cover_fetched: bool,
}
/// Outcome of [`ResyncService::resync_chapter`]. `Fetched(pages)` is the
/// success case; `Skipped` means the source row was already gone or the
/// chapter had no live source.
#[derive(Debug, Clone)]
pub enum ChapterResyncOutcome {
Fetched { chapter_id: Uuid, pages: usize },
Skipped { chapter_id: Uuid, reason: String },
}
/// Service exposed by the daemon to the admin API. Optional on
/// [`AppState`] — `None` when the crawler daemon is disabled
/// (`CRAWLER_DAEMON=false`), in which case admin handlers return 503.
#[async_trait]
pub trait ResyncService: Send + Sync {
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome>;
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome>;
}
/// Errors with a stable shape so the API layer can map them to the
/// right HTTP status (404 vs 422 vs 5xx). Anything else surfaces as a
/// generic 500.
#[derive(Debug, thiserror::Error)]
pub enum ResyncError {
#[error("manga has no source to resync from")]
NoMangaSource,
#[error("chapter has no source to resync from")]
NoChapterSource,
}
pub struct RealResyncService {
pub browser_manager: Arc<BrowserManager>,
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub http: reqwest::Client,
pub rate: Arc<HostRateLimiters>,
pub download_allowlist: DownloadAllowlist,
pub max_image_bytes: usize,
pub tor: Option<Arc<TorController>>,
}
#[async_trait]
impl ResyncService for RealResyncService {
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
// Pick the freshest live source row. Multi-source mangas
// (theoretical — only one Source impl today) get the row whose
// `last_seen_at` is newest; soft-dropped rows are skipped.
let row: Option<(String, String, String)> = sqlx::query_as(
"SELECT source_id, source_manga_key, source_url \
FROM manga_sources \
WHERE manga_id = $1 AND dropped_at IS NULL \
ORDER BY last_seen_at DESC \
LIMIT 1",
)
.bind(manga_id)
.fetch_optional(&self.db)
.await
.context("look up manga_sources for resync")?;
let Some((_source_id, source_manga_key, source_url)) = row else {
return Err(ResyncError::NoMangaSource.into());
};
let lease = self
.browser_manager
.acquire()
.await
.context("acquire browser lease for manga resync")?;
let browser_ref: &chromiumoxide::Browser = &lease;
let ctx = FetchContext {
browser: browser_ref,
rate: &self.rate,
tor: self.tor.as_deref(),
};
// Parse chapters too — a force resync is "make this manga fully
// current," not just metadata. The full pipeline handles the
// partial-render guard for us; we replicate the same caution
// here by skipping the chapter sync when the parser returned
// empty but the manga previously had chapters.
let source = TargetSource::new(source_url.clone());
let r = SourceMangaRef {
source_manga_key: source_manga_key.clone(),
title: String::new(),
url: source_url.clone(),
};
let manga = source
.fetch_manga(&ctx, &r)
.await
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
// Partial-render guard: same logic as run_metadata_pass.
let source_id = source.id();
if !manga.chapters.is_empty() || {
let prior = repo::crawler::live_chapter_count_for_source_manga(
&self.db,
source_id,
&source_manga_key,
)
.await
.unwrap_or(0);
prior == 0
} {
// Either the new fetch surfaced chapters, or there were
// none before either — chapter sync is safe to run.
} else {
tracing::warn!(
%manga_id,
source_url = %source_url,
"resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
);
}
let upsert = repo::crawler::upsert_manga_from_source(
&self.db,
source_id,
&source_url,
&manga,
)
.await
.with_context(|| format!("upsert_manga_from_source during resync of {manga_id}"))?;
// Cover refetch: force-download regardless of UpsertStatus.
// Admin clicked "resync" because they want the cover too.
let mut cover_fetched = false;
if let Some(cover_url) = manga.cover_url.as_deref() {
match pipeline::download_and_store_cover(
&self.db,
self.storage.as_ref(),
&self.http,
&self.rate,
&source_url,
upsert.manga_id,
cover_url,
&self.download_allowlist,
self.max_image_bytes,
)
.await
{
Ok(()) => cover_fetched = true,
Err(e) => tracing::warn!(
%manga_id,
error = ?e,
"resync_manga: cover download failed"
),
}
}
// Chapter sync — only when the partial-render guard above
// didn't bail.
let prior_chapter_count = repo::crawler::live_chapter_count_for_source_manga(
&self.db,
source_id,
&source_manga_key,
)
.await
.unwrap_or(0);
if !manga.chapters.is_empty() || prior_chapter_count == 0 {
match repo::crawler::sync_manga_chapters(
&self.db,
source_id,
upsert.manga_id,
&manga.chapters,
)
.await
{
Ok(diff) => tracing::info!(
%manga_id,
new = diff.new,
refreshed = diff.refreshed,
dropped = diff.dropped,
"resync_manga: chapters synced"
),
Err(e) => tracing::warn!(
%manga_id,
error = ?e,
"resync_manga: chapter sync failed"
),
}
}
drop(lease);
Ok(MangaResyncOutcome {
manga_id: upsert.manga_id,
metadata_status: upsert.status,
cover_fetched,
})
}
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
let row = repo::chapter::dispatch_target(&self.db, chapter_id)
.await
.context("look up chapter_sources for resync")?;
let Some((manga_id, source_url)) = row else {
return Err(ResyncError::NoChapterSource.into());
};
let lease = self
.browser_manager
.acquire()
.await
.context("acquire browser lease for chapter resync")?;
let result = content::sync_chapter_content(
&lease,
&self.db,
self.storage.as_ref(),
&self.http,
&self.rate,
chapter_id,
manga_id,
&source_url,
true,
&self.download_allowlist,
self.max_image_bytes,
self.tor.as_deref(),
)
.await;
drop(lease);
match result? {
SyncOutcome::Fetched { pages } => {
Ok(ChapterResyncOutcome::Fetched { chapter_id, pages })
}
SyncOutcome::Skipped => Ok(ChapterResyncOutcome::Skipped {
chapter_id,
reason: "chapter already had pages on disk".to_string(),
}),
SyncOutcome::SessionExpired => {
anyhow::bail!("source session expired — operator must refresh PHPSESSID")
}
}
}
}

View File

@@ -21,6 +21,11 @@ pub enum AppError {
PayloadTooLarge(String),
#[error("unsupported media type: {0}")]
UnsupportedMediaType(String),
/// 503 — a feature is currently unavailable, distinct from a 5xx
/// internal error. Used when admin actions require the crawler
/// daemon but it's been disabled (`CRAWLER_DAEMON=false`).
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
/// 429 with an optional `Retry-After` header value (in seconds).
#[error("too many requests")]
TooManyRequests {
@@ -56,6 +61,7 @@ impl AppError {
AppError::Conflict(_) => "conflict",
AppError::PayloadTooLarge(_) => "payload_too_large",
AppError::UnsupportedMediaType(_) => "unsupported_media_type",
AppError::ServiceUnavailable(_) => "service_unavailable",
AppError::TooManyRequests { .. } => "too_many_requests",
AppError::ValidationFailed { .. } => "validation_failed",
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
@@ -85,6 +91,9 @@ impl IntoResponse for AppError {
AppError::UnsupportedMediaType(msg) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None)
}
AppError::ServiceUnavailable(msg) => {
(StatusCode::SERVICE_UNAVAILABLE, msg.clone(), None)
}
AppError::TooManyRequests { retry_after_secs } => {
// Emit `Retry-After: N` (RFC 6585 §4) so a well-behaved
// client can back off correctly. Done by building the

View File

@@ -542,6 +542,51 @@ pub async fn mark_run_completed(pool: &PgPool, source_id: &str) -> sqlx::Result<
Ok(())
}
/// List mangas whose `cover_image_path IS NULL` but a live
/// `manga_sources` row still attaches them to a source. The bounded
/// result feeds the cover-backfill pass in [`crate::crawler::pipeline`]:
/// each entry is one (manga, freshest source row) pair where a cover
/// re-download is in order.
///
/// Per-manga deduplication uses `DISTINCT ON (m.id)` keyed on the row
/// with the newest `last_seen_at`, so a manga that's surfaced by
/// multiple sources only produces one row (the freshest). Sort is
/// stable for tests.
pub async fn list_missing_covers(
pool: &PgPool,
max: i64,
) -> sqlx::Result<Vec<MissingCoverEntry>> {
let rows: Vec<(Uuid, String, String)> = sqlx::query_as(
r#"
SELECT DISTINCT ON (m.id) m.id, ms.source_manga_key, ms.source_url
FROM mangas m
JOIN manga_sources ms ON ms.manga_id = m.id
WHERE m.cover_image_path IS NULL
AND ms.dropped_at IS NULL
ORDER BY m.id, ms.last_seen_at DESC
LIMIT $1
"#,
)
.bind(max)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(manga_id, source_manga_key, source_url)| MissingCoverEntry {
manga_id,
source_manga_key,
source_url,
})
.collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingCoverEntry {
pub manga_id: Uuid,
pub source_manga_key: String,
pub source_url: String,
}
/// Read the recovery flag for `source_id`. A missing row OR an
/// unparseable value reads as `true` ("clean") — the former covers the
/// first-ever run on a virgin DB (no recovery needed), the latter

View File

@@ -0,0 +1,350 @@
//! Integration tests for the admin force-resync endpoints.
//!
//! Real resync work requires Chromium, so these tests swap in a stub
//! [`ResyncService`] to assert the handler-level contract: routing,
//! admin gate, 503 when the daemon is disabled, 404 / 422 mapping for
//! missing-resource / no-source cases, and the audit-log side effect.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
use mangalord::crawler::resync::{
ChapterResyncOutcome, MangaResyncOutcome, ResyncError, ResyncService,
};
use mangalord::repo;
use mangalord::repo::crawler::UpsertStatus;
/// Stub that records call counts and returns a canned outcome.
struct StubResync {
manga_calls: AtomicUsize,
chapter_calls: AtomicUsize,
/// When true, returns NoMangaSource / NoChapterSource.
no_source: bool,
}
impl StubResync {
fn new() -> Arc<Self> {
Arc::new(Self {
manga_calls: AtomicUsize::new(0),
chapter_calls: AtomicUsize::new(0),
no_source: false,
})
}
fn no_source() -> Arc<Self> {
Arc::new(Self {
manga_calls: AtomicUsize::new(0),
chapter_calls: AtomicUsize::new(0),
no_source: true,
})
}
}
#[async_trait]
impl ResyncService for StubResync {
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
self.manga_calls.fetch_add(1, Ordering::SeqCst);
if self.no_source {
return Err(ResyncError::NoMangaSource.into());
}
Ok(MangaResyncOutcome {
manga_id,
metadata_status: UpsertStatus::Updated,
cover_fetched: true,
})
}
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
self.chapter_calls.fetch_add(1, Ordering::SeqCst);
if self.no_source {
return Err(ResyncError::NoChapterSource.into());
}
Ok(ChapterResyncOutcome::Fetched {
chapter_id,
pages: 7,
})
}
}
async fn promote_admin(pool: &PgPool, username: &str) {
let u = repo::user::find_by_username(pool, username)
.await
.unwrap()
.unwrap();
repo::user::set_is_admin_unchecked(pool, u.id, true)
.await
.unwrap();
}
async fn insert_manga(pool: &PgPool, title: &str) -> Uuid {
let (id,): (Uuid,) = sqlx::query_as(
"INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id",
)
.bind(title)
.fetch_one(pool)
.await
.unwrap();
id
}
async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, pages: i32) -> Uuid {
let (id,): (Uuid,) = sqlx::query_as(
"INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id",
)
.bind(manga_id)
.bind(number)
.bind(pages)
.fetch_one(pool)
.await
.unwrap();
id
}
// ----- manga resync ---------------------------------------------------------
#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_calls_service_and_returns_refreshed_detail(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "Hello").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
// Stub returned Updated + cover_fetched=true.
assert_eq!(body["metadata_status"], "updated");
assert_eq!(body["cover_fetched"], true);
// Response includes the refreshed manga detail.
assert_eq!(body["manga"]["id"], manga_id.to_string());
assert_eq!(body["manga"]["title"], "Hello");
assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 1);
// Audit row written.
let (audit_count,): (i64,) =
sqlx::query_as("SELECT count(*) FROM admin_audit WHERE action = 'manga_resync' AND target_id = $1")
.bind(manga_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audit_count, 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_returns_404_for_unknown_id(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/mangas/{}/resync", Uuid::new_v4()),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// Service must not have been called when the manga doesn't exist.
assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_maps_no_source_to_422(pool: PgPool) {
let stub = StubResync::no_source();
let h = common::harness_with_resync(pool.clone(), stub);
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "Manual upload, no crawler source").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["details"]["manga"], "no_source");
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_returns_503_when_daemon_disabled(pool: PgPool) {
let h = common::harness(pool.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "Z").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "service_unavailable");
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_requires_admin(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub);
// Non-admin user.
let (_u, cookie) = common::register_user(&h.app).await;
let manga_id = insert_manga(&pool, "M").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ----- chapter resync -------------------------------------------------------
#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_calls_service_and_returns_refreshed_chapter(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "M").await;
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["outcome"], "fetched");
assert_eq!(body["pages"], 7);
assert_eq!(body["chapter"]["id"], chapter_id.to_string());
assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 1);
let (audit_count,): (i64,) = sqlx::query_as(
"SELECT count(*) FROM admin_audit WHERE action = 'chapter_resync' AND target_id = $1",
)
.bind(chapter_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audit_count, 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_returns_404_for_unknown_id(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/chapters/{}/resync", Uuid::new_v4()),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_maps_no_source_to_422(pool: PgPool) {
let stub = StubResync::no_source();
let h = common::harness_with_resync(pool.clone(), stub);
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "M").await;
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["details"]["chapter"], "no_source");
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_returns_503_when_daemon_disabled(pool: PgPool) {
let h = common::harness(pool.clone());
let (username, cookie) = common::register_user(&h.app).await;
promote_admin(&pool, &username).await;
let manga_id = insert_manga(&pool, "M").await;
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_requires_admin(pool: PgPool) {
let stub = StubResync::new();
let h = common::harness_with_resync(pool.clone(), stub);
let (_u, cookie) = common::register_user(&h.app).await;
let manga_id = insert_manga(&pool, "M").await;
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View File

@@ -49,6 +49,7 @@ fn admin_test_router(pool: PgPool) -> (Router, TempDir) {
auth,
upload: UploadConfig::default(),
auth_limiter,
resync: None,
};
let app = Router::new()
.nest("/api/v1", api::routes())

View File

@@ -74,6 +74,10 @@ fn harness_with_auth_config(
max_file_bytes: 256 * 1024,
},
auth_limiter,
// Default harness has no crawler daemon wired up; admin resync
// handlers return 503 in this config. Tests that need a stub
// resync service swap it in via `harness_with_resync`.
resync: None,
};
Harness { app: router(state), _storage_dir: storage_dir }
}
@@ -124,6 +128,37 @@ pub fn harness_with_auth_rate_limit(
harness_with_auth_config(pool, storage, storage_dir, auth)
}
/// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub
/// into `AppState.resync`. Used by the admin resync tests so the
/// endpoint path is exercised without standing up a real Chromium.
pub fn harness_with_resync(
pool: PgPool,
resync: Arc<dyn mangalord::crawler::resync::ResyncService>,
) -> Harness {
let storage_dir = tempfile::tempdir().expect("tempdir");
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
let auth = AuthConfig {
cookie_secure: false,
..AuthConfig::default()
};
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
let state = AppState {
db: pool,
storage,
auth,
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
},
auth_limiter,
resync: Some(resync),
};
Harness {
app: router(state),
_storage_dir: storage_dir,
}
}
/// Wraps a real `Storage` and fails on the N-th `put` call so tests can
/// assert that handlers roll their DB writes back when storage errors
/// mid-upload. Reads and other operations delegate to `inner`.

View File

@@ -829,6 +829,107 @@ async fn sync_tags_garbage_collects_orphan_user_attachments(pool: PgPool) {
assert_eq!(orphan_rows, 0, "orphan user-attached tag should be reaped");
}
// ---- list_missing_covers ---------------------------------------------------
#[sqlx::test(migrations = "./migrations")]
async fn list_missing_covers_only_returns_rows_without_cover(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let with_cover = sample_manga("with", "With Cover", "h1");
let without_cover = sample_manga("without", "No Cover", "h2");
let _w = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/with", &with_cover)
.await
.unwrap();
let nc = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/without", &without_cover)
.await
.unwrap();
// Manually set a cover for `with` only.
sqlx::query("UPDATE mangas SET cover_image_path = 'mangas/x/cover.jpg' WHERE id = $1")
.bind(_w.manga_id)
.execute(&pool)
.await
.unwrap();
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
assert_eq!(entries.len(), 1, "exactly the manga without a cover");
assert_eq!(entries[0].manga_id, nc.manga_id);
assert_eq!(entries[0].source_manga_key, "without");
assert_eq!(entries[0].source_url, "https://x.example/without");
}
#[sqlx::test(migrations = "./migrations")]
async fn list_missing_covers_skips_dropped_source_rows(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo", "h1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
sqlx::query("UPDATE manga_sources SET dropped_at = NOW() WHERE manga_id = $1")
.bind(up.manga_id)
.execute(&pool)
.await
.unwrap();
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
assert!(
entries.is_empty(),
"dropped-source mangas must not be backfilled — no live source to fetch from"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_missing_covers_respects_limit(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
for i in 0..5 {
let key = format!("m{i}");
let url = format!("https://x.example/{key}");
let m = sample_manga(&key, &format!("M{i}"), &format!("h{i}"));
let _ = crawler::upsert_manga_from_source(&pool, "target", &url, &m)
.await
.unwrap();
}
let entries = crawler::list_missing_covers(&pool, 3).await.unwrap();
assert_eq!(entries.len(), 3, "limit caps the result set");
}
#[sqlx::test(migrations = "./migrations")]
async fn list_missing_covers_deduplicates_per_manga(pool: PgPool) {
// A manga surfaced by two sources should produce ONE backfill
// entry, not two — otherwise the per-tick cap could be eaten by
// duplicates and starve other mangas.
crawler::ensure_source(&pool, "src-a", "A", "https://a.example")
.await
.unwrap();
crawler::ensure_source(&pool, "src-b", "B", "https://b.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo", "h1");
let up = crawler::upsert_manga_from_source(&pool, "src-a", "https://a.example/foo", &m)
.await
.unwrap();
// Second source attaches to the SAME manga row.
sqlx::query(
"INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url) \
VALUES ($1, $2, $3, $4)",
)
.bind("src-b")
.bind("foo-on-b")
.bind(up.manga_id)
.bind("https://b.example/foo")
.execute(&pool)
.await
.unwrap();
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
assert_eq!(entries.len(), 1, "DISTINCT ON (m.id) collapses duplicate source rows");
}
#[sqlx::test(migrations = "./migrations")]
async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")

View File

@@ -10,6 +10,15 @@ import { test, expect, type Page } from '@playwright/test';
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
async function mockAnonymous(page: Page) {
// Force public mode so the root +layout.ts doesn't bounce us to /login
// (a dev backend with PRIVATE_MODE=true must not leak into E2E runs).
await page.route('**/api/v1/auth/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
});
});
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({
status: 401,
@@ -69,3 +78,53 @@ test('search updates the manga list', async ({ page }) => {
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
expect(lastSearch).toBe('berserk');
});
test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => {
await mockAnonymous(page);
// Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75.
const TOTAL = 75;
function mangaAt(i: number) {
return {
id: `m${i}`,
title: `Manga ${i}`,
author: 'Test',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: []
};
}
await page.route('**/api/v1/mangas*', async (route) => {
const url = new URL(route.request().url());
const limit = Number(url.searchParams.get('limit') ?? '50');
const offset = Number(url.searchParams.get('offset') ?? '0');
const items: ReturnType<typeof mangaAt>[] = [];
for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) {
items.push(mangaAt(i));
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items,
page: { limit, offset, total: TOTAL }
})
});
});
await page.goto('/');
await expect(page.getByTestId('manga-total')).toContainText('Showing 150 of 75');
await expect(page.getByTestId('manga-list')).toContainText('Manga 1');
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75');
await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click();
await expect(page).toHaveURL(/[?&]page=2(&|$)/);
await expect(page.getByTestId('manga-total')).toContainText('Showing 5175 of 75');
await expect(page.getByTestId('manga-list')).toContainText('Manga 75');
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1');
});

View File

@@ -0,0 +1,67 @@
import { test, expect, type Page } from '@playwright/test';
// Guards the title-on-nav behavior: without this, a stale title from
// the last manga / author page lingers when the user navigates to a
// generic page like /upload.
async function mockAnonymous(page: Page) {
await page.route('**/api/v1/auth/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
});
});
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
});
});
await page.route('**/api/v1/mangas*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
});
});
}
test('static route titles use the brand-first layout map', async ({ page }) => {
await mockAnonymous(page);
await page.goto('/');
await expect(page).toHaveTitle('Mangalord');
await page.goto('/upload');
await expect(page).toHaveTitle('Mangalord | Upload');
await page.goto('/login');
await expect(page).toHaveTitle('Mangalord | Login');
await page.goto('/bookmarks');
await expect(page).toHaveTitle('Mangalord | Bookmarks');
await page.goto('/collections');
await expect(page).toHaveTitle('Mangalord | Collections');
});
test('title updates when navigating away from a content page', async ({ page }) => {
await mockAnonymous(page);
// Pretend we just left a manga detail page — the document title
// would have been overridden to "Mangalord | Berserk". Use evaluate
// to set it synthetically so we can assert the regression cleanly
// even though the dynamic page itself isn't mocked here.
await page.goto('/');
await page.evaluate(() => {
document.title = 'Mangalord | Berserk';
});
expect(await page.title()).toBe('Mangalord | Berserk');
// Client-side nav to /upload — the root layout must reassert its
// mapped title or the stale "Berserk" lingers.
await page.goto('/upload');
await expect(page).toHaveTitle('Mangalord | Upload');
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.48.0",
"version": "0.50.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -14,7 +14,9 @@ import {
createAdminUser,
listAdminMangas,
listAdminChapters,
getSystemStats
getSystemStats,
resyncManga,
resyncChapter
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -242,4 +244,88 @@ describe('admin api client', () => {
const s = await getSystemStats();
expect(s.disk).toBeNull();
});
// ---- force resync ----
it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => {
const resp = {
manga: {
id: 'm-1',
title: 'T',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: 'mangas/m-1/cover.jpg',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
authors: [],
genres: [],
tags: []
},
metadata_status: 'updated',
cover_fetched: true
};
fetchSpy.mockResolvedValueOnce(ok(resp));
const got = await resyncManga('m-1');
expect(got.metadata_status).toBe('updated');
expect(got.cover_fetched).toBe(true);
expect(got.manga.id).toBe('m-1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/resync$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
});
it('resyncManga surfaces 503 service_unavailable when the daemon is off', async () => {
fetchSpy.mockResolvedValueOnce(
envelope(503, 'service_unavailable', 'crawler daemon is disabled')
);
await expect(resyncManga('m-1')).rejects.toMatchObject({
status: 503,
code: 'service_unavailable'
});
});
it('resyncChapter POSTs to /v1/admin/chapters/{id}/resync and returns the envelope', async () => {
const resp = {
chapter: {
id: 'c-1',
manga_id: 'm-1',
number: 1,
title: 'Foo',
page_count: 7,
created_at: '2026-01-01T00:00:00Z'
},
outcome: 'fetched',
pages: 7
};
fetchSpy.mockResolvedValueOnce(ok(resp));
const got = await resyncChapter('c-1');
expect(got.outcome).toBe('fetched');
expect(got.pages).toBe(7);
expect(got.chapter.page_count).toBe(7);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/chapters\/c-1\/resync$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
});
it('resyncChapter handles the "skipped" outcome envelope', async () => {
const resp = {
chapter: {
id: 'c-1',
manga_id: 'm-1',
number: 1,
title: null,
page_count: 7,
created_at: '2026-01-01T00:00:00Z'
},
outcome: 'skipped',
pages: null
};
fetchSpy.mockResolvedValueOnce(ok(resp));
const got = await resyncChapter('c-1');
expect(got.outcome).toBe('skipped');
expect(got.pages).toBeNull();
});
});

View File

@@ -5,6 +5,8 @@
import { request, type Page } from './client';
import type { User } from './auth';
import type { MangaDetail } from './mangas';
import type { Chapter } from './chapters';
// ---- users -----------------------------------------------------------------
@@ -176,3 +178,39 @@ export type SystemStats = {
export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>('/v1/admin/system');
}
// ---- force resync ----------------------------------------------------------
export type MangaResyncResponse = {
manga: MangaDetail;
metadata_status: 'new' | 'updated' | 'unchanged';
cover_fetched: boolean;
};
export type ChapterResyncResponse = {
chapter: Chapter;
outcome: 'fetched' | 'skipped';
/** Page count when `outcome === 'fetched'`; null when skipped. */
pages: number | null;
};
/** POST /v1/admin/mangas/:id/resync — refetches metadata + cover from
* the manga's live crawler source. Long-running (one HTTP request per
* Chromium nav + image download), so the UI should disable the trigger
* and surface progress. */
export async function resyncManga(id: string): Promise<MangaResyncResponse> {
return request<MangaResyncResponse>(
`/v1/admin/mangas/${encodeURIComponent(id)}/resync`,
{ method: 'POST' }
);
}
/** POST /v1/admin/chapters/:id/resync — force-refetches a chapter's
* pages even if `page_count > 0`. Same long-running caveat as
* `resyncManga`. */
export async function resyncChapter(id: string): Promise<ChapterResyncResponse> {
return request<ChapterResyncResponse>(
`/v1/admin/chapters/${encodeURIComponent(id)}/resync`,
{ method: 'POST' }
);
}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
type Props = {
page: number;
totalPages: number;
onChange: (page: number) => void;
testid?: string;
};
let { page, totalPages, onChange, testid }: Props = $props();
type Slot = number | 'ellipsis';
// Compact layout: always show first + last, surround the current page with
// its direct neighbours, and use "…" to elide the rest. Keeps the bar to
// at most 7 buttons regardless of totalPages.
function buildSlots(p: number, total: number): Slot[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const out: Slot[] = [1];
if (p <= 4) {
for (let i = 2; i <= 5; i++) out.push(i);
out.push('ellipsis');
out.push(total);
} else if (p >= total - 3) {
out.push('ellipsis');
for (let i = total - 4; i <= total; i++) out.push(i);
} else {
out.push('ellipsis');
out.push(p - 1);
out.push(p);
out.push(p + 1);
out.push('ellipsis');
out.push(total);
}
return out;
}
const slots = $derived(buildSlots(page, totalPages));
</script>
{#if totalPages > 1}
<nav class="pager" aria-label="Pagination" data-testid={testid}>
<button
type="button"
class="step"
disabled={page <= 1}
onclick={() => onChange(page - 1)}
aria-label="Previous page"
>
Prev
</button>
{#each slots as slot, i (i)}
{#if slot === 'ellipsis'}
<span class="ellipsis" aria-hidden="true"></span>
{:else}
<button
type="button"
class="num"
class:active={slot === page}
aria-current={slot === page ? 'page' : undefined}
aria-label={`Go to page ${slot}`}
onclick={() => onChange(slot)}
>
{slot}
</button>
{/if}
{/each}
<button
type="button"
class="step"
disabled={page >= totalPages}
onclick={() => onChange(page + 1)}
aria-label="Next page"
>
Next
</button>
</nav>
{/if}
<style>
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
margin: var(--space-4) 0;
justify-content: center;
}
.step,
.num {
min-width: 36px;
height: 36px;
padding: 0 var(--space-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
cursor: pointer;
font-size: var(--font-sm);
}
.step:hover:not(:disabled),
.num:hover:not(.active) {
border-color: var(--primary);
}
.step:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.num.active {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
cursor: default;
}
.ellipsis {
padding: 0 var(--space-1);
color: var(--text-muted);
user-select: none;
}
</style>

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Pager from './Pager.svelte';
afterEach(() => cleanup());
describe('Pager', () => {
it('renders nothing when totalPages <= 1', () => {
const { container } = render(Pager, { props: { page: 1, totalPages: 1, onChange: () => {} } });
expect(container.querySelector('nav')).toBeNull();
});
it('disables Prev on the first page and Next on the last', () => {
const { rerender } = render(Pager, {
props: { page: 1, totalPages: 5, onChange: () => {} }
});
expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(false);
rerender({ page: 5, totalPages: 5, onChange: () => {} });
expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(true);
});
it('marks the current page button as aria-current', () => {
render(Pager, { props: { page: 3, totalPages: 5, onChange: () => {} } });
const current = screen.getByRole('button', { name: /go to page 3/i });
expect(current.getAttribute('aria-current')).toBe('page');
});
it('fires onChange with the clicked page number', async () => {
const onChange = vi.fn();
render(Pager, { props: { page: 1, totalPages: 5, onChange } });
screen.getByRole('button', { name: /go to page 3/i }).click();
expect(onChange).toHaveBeenCalledWith(3);
});
it('Prev decrements and Next increments via onChange', () => {
const onChange = vi.fn();
render(Pager, { props: { page: 3, totalPages: 5, onChange } });
screen.getByRole('button', { name: /prev/i }).click();
screen.getByRole('button', { name: /next/i }).click();
expect(onChange).toHaveBeenNthCalledWith(1, 2);
expect(onChange).toHaveBeenNthCalledWith(2, 4);
});
it('shows every page button when totalPages <= 7', () => {
render(Pager, { props: { page: 4, totalPages: 7, onChange: () => {} } });
for (let n = 1; n <= 7; n++) {
expect(screen.getByRole('button', { name: new RegExp(`go to page ${n}$`, 'i') })).toBeTruthy();
}
});
it('collapses middle pages with ellipsis when totalPages > 7 and current is in the middle', () => {
render(Pager, { props: { page: 10, totalPages: 24, onChange: () => {} } });
// First and last are always shown
expect(screen.getByRole('button', { name: /go to page 1$/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /go to page 24$/i })).toBeTruthy();
// Current and direct neighbours are shown
expect(screen.getByRole('button', { name: /go to page 9$/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /go to page 10$/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /go to page 11$/i })).toBeTruthy();
// Distant pages are NOT rendered as buttons
expect(screen.queryByRole('button', { name: /go to page 2$/i })).toBeNull();
expect(screen.queryByRole('button', { name: /go to page 23$/i })).toBeNull();
// Ellipsis appears on both sides
const ellipses = screen.getAllByText('…');
expect(ellipses.length).toBeGreaterThanOrEqual(2);
});
it('does not duplicate boundary buttons when current is near the edge', () => {
render(Pager, { props: { page: 2, totalPages: 20, onChange: () => {} } });
// Each page button rendered should be unique — no duplicate "go to page 1"
const first = screen.getAllByRole('button', { name: /go to page 1$/i });
expect(first.length).toBe(1);
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { logout } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { preferences } from '$lib/preferences.svelte';
@@ -18,6 +19,32 @@
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
// Static-route title map. Dynamic pages (manga / author / collection /
// chapter) override this via their own <svelte:head><title>, since the
// title depends on data the layout doesn't have. Routes omitted here
// (notably the dynamic ones) fall through to the bare brand and rely
// on the page to set the descriptive form.
const STATIC_TITLES: Record<string, string> = {
'/': 'Mangalord',
'/login': 'Mangalord | Login',
'/register': 'Mangalord | Register',
'/upload': 'Mangalord | Upload',
'/bookmarks': 'Mangalord | Bookmarks',
'/collections': 'Mangalord | Collections',
'/profile': 'Mangalord | Profile',
'/profile/account': 'Mangalord | Account',
'/profile/bookmarks': 'Mangalord | Bookmarks',
'/profile/collections': 'Mangalord | Collections',
'/profile/history': 'Mangalord | Reading history',
'/profile/preferences': 'Mangalord | Preferences',
'/admin': 'Mangalord | Admin',
'/admin/mangas': 'Mangalord | Admin · Mangas',
'/admin/users': 'Mangalord | Admin · Users',
'/admin/system': 'Mangalord | Admin · System'
};
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
// Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial
@@ -78,6 +105,10 @@
}
</script>
<svelte:head>
<title>{layoutTitle}</title>
</svelte:head>
<header bind:this={headerEl}>
<nav aria-label="primary">
<a class="brand" href="/">Mangalord</a>

View File

@@ -13,10 +13,13 @@
import { listTags, type Tag } from '$lib/api/tags';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import Search from '@lucide/svelte/icons/search';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50;
let mangas: MangaCardData[] = $state([]);
let search = $state('');
let sort: MangaSort = $state('recent');
@@ -36,11 +39,21 @@
let total: number | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
let currentPage = $state(1);
const activeFilterCount = $derived(
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
);
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
);
// 1-indexed range like "51100 of 237", clamped to the actual loaded set
// in case the last page is short.
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1);
const rangeEnd = $derived((currentPage - 1) * PAGE_SIZE + mangas.length);
async function load() {
loading = true;
error = null;
@@ -50,7 +63,9 @@
status: statusFilter || undefined,
genreIds: selectedGenres.map((g) => g.id),
tagIds: selectedTags.map((t) => t.id),
sort
sort,
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
});
mangas = result.items;
total = result.page.total;
@@ -71,11 +86,29 @@
params.set('genres', selectedGenres.map((g) => g.id).join(','));
if (selectedTags.length)
params.set('tags', selectedTags.map((t) => t.id).join(','));
if (currentPage > 1) params.set('page', String(currentPage));
const qs = params.toString();
const url = qs ? `/?${qs}` : '/';
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
}
// Filter / search / sort changes invalidate the current page — drop back
// to page 1 so the user isn't stranded on an out-of-range page when the
// result set shrinks. Direct page navigation calls `goToPage()` instead.
function resetAndReload() {
currentPage = 1;
syncUrl();
load();
}
function goToPage(p: number) {
if (p === currentPage) return;
currentPage = p;
syncUrl();
load();
if (browser) window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function hydrateFromUrl() {
// Parse the query and resolve the supplied ids back to full Tag /
// Genre objects so the chip rows render real labels.
@@ -100,6 +133,8 @@
const tags = await listTags({ limit: 50 });
selectedTags = tags.filter((t) => tagIds.includes(t.id));
}
const pageParam = Number(url.searchParams.get('page') ?? '1');
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
// Open the filters panel if anything is active so the user can see why.
if (statusFilter || selectedGenres.length || selectedTags.length) {
filtersOpen = true;
@@ -108,32 +143,27 @@
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
syncUrl();
await load();
resetAndReload();
}
function onSortChange() {
syncUrl();
load();
resetAndReload();
}
function onStatusChange() {
syncUrl();
load();
resetAndReload();
}
function toggleGenre(g: Genre) {
selectedGenres = selectedGenres.some((x) => x.id === g.id)
? selectedGenres.filter((x) => x.id !== g.id)
: [...selectedGenres, g];
syncUrl();
load();
resetAndReload();
}
function removeTag(t: Tag) {
selectedTags = selectedTags.filter((x) => x.id !== t.id);
syncUrl();
load();
resetAndReload();
}
function pickTag(t: Tag) {
@@ -143,8 +173,7 @@
tagDraft = '';
tagSuggestions = [];
tagSuggestHighlight = -1;
syncUrl();
load();
resetAndReload();
}
function onTagDraftInput() {
@@ -192,8 +221,7 @@
statusFilter = '';
selectedGenres = [];
selectedTags = [];
syncUrl();
load();
resetAndReload();
}
onMount(async () => {
@@ -383,7 +411,7 @@
{:else}
{#if total !== null}
<p class="count" data-testid="manga-total">
Showing {mangas.length} of {total}
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="manga-list">
@@ -391,6 +419,12 @@
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="manga-pager"
/>
{/if}
<style>

View File

@@ -71,16 +71,19 @@
>
<input
type="search"
placeholder="search by title"
placeholder="Search by title"
bind:value={search}
data-testid="admin-mangas-search"
/>
<label class="sync-label">
<span>Sync state</span>
<select bind:value={syncFilter} aria-label="sync state">
<option value="">all states</option>
<option value="in_progress">in progress</option>
<option value="dropped">dropped</option>
<option value="synced">synced</option>
<option value="">All</option>
<option value="in_progress">In progress</option>
<option value="dropped">Dropped</option>
<option value="synced">Synced</option>
</select>
</label>
<button type="submit">Search</button>
</form>
@@ -173,17 +176,28 @@
}
form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
min-width: 0;
max-width: 24rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
.sync-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);

View File

@@ -1,15 +1,33 @@
<script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data } = $props();
const author = $derived(data.author);
const mangas = $derived(data.mangas);
const total = $derived(data.total);
const currentPage = $derived(data.currentPage);
const pageSize = $derived(data.pageSize);
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / pageSize) : 1
);
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1);
const rangeEnd = $derived((currentPage - 1) * pageSize + mangas.length);
function goToPage(p: number) {
if (p === currentPage) return;
const url = new URL($page.url);
if (p === 1) url.searchParams.delete('page');
else url.searchParams.set('page', String(p));
goto(url.pathname + url.search, { noScroll: false });
}
</script>
<svelte:head>
<title>{author.name} — Mangalord</title>
<title>Mangalord | {author.name}</title>
</svelte:head>
<nav class="back">
@@ -34,7 +52,7 @@
{:else}
{#if total != null}
<p class="meta" data-testid="author-shown-of-total">
Showing {mangas.length} of {total}
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="author-manga-list">
@@ -42,6 +60,12 @@
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="author-pager"
/>
{/if}
<style>

View File

@@ -5,13 +5,27 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const PAGE_SIZE = 50;
export const load: PageLoad = async ({ params, url }) => {
const pageParam = Number(url.searchParams.get('page') ?? '1');
const currentPage =
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
try {
const [author, mangas] = await Promise.all([
getAuthor(params.id),
listAuthorMangas(params.id, { limit: 50 })
listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
})
]);
return { author, mangas: mangas.items, total: mangas.page.total };
return {
author,
mangas: mangas.items,
total: mangas.page.total,
currentPage,
pageSize: PAGE_SIZE
};
} catch (e) {
// 404 surfaces as a real SvelteKit error so the framework shell
// renders the standard not-found page instead of the route's

View File

@@ -7,10 +7,6 @@
const error = $derived(data.error);
</script>
<svelte:head>
<title>Bookmarks — Mangalord</title>
</svelte:head>
<h1>Bookmarks</h1>
{#if error}

View File

@@ -5,10 +5,6 @@
const collections = $derived(data.collections);
</script>
<svelte:head>
<title>Collections — Mangalord</title>
</svelte:head>
<h1>Collections</h1>
{#if !data.authenticated}

View File

@@ -75,7 +75,7 @@
</script>
<svelte:head>
<title>{collection.name} — Mangalord</title>
<title>Mangalord | {collection.name}</title>
</svelte:head>
<nav class="back">

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { fileUrl, ApiError } from '$lib/api/client';
import { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
import {
attachTag,
detachTag,
type AuthorRef,
type GenreRef,
type MangaDetail,
type TagRef
} from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin';
import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
@@ -16,9 +18,15 @@
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Pencil from '@lucide/svelte/icons/pencil';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props();
const manga = $derived(data.manga);
// `manga` is locally overridable so a successful force resync can
// swap in the refreshed detail (new cover URL, refreshed status,
// etc.) without a router reload. Falls back to the server-loaded
// data otherwise.
let mangaOverride = $state<MangaDetail | null>(null);
const manga = $derived<MangaDetail>(mangaOverride ?? data.manga);
const chapters = $derived(data.chapters);
const readProgress = $derived(data.readProgress);
/** Chapter row from the local chapters list when present (so we
@@ -171,10 +179,35 @@
const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing');
let collectionModalOpen = $state(false);
// ---- Admin force resync ----
let resyncBusy = $state(false);
let resyncMessage = $state<{ kind: 'ok' | 'err'; text: string } | null>(null);
async function forceResync() {
if (!session.user?.is_admin || resyncBusy) return;
resyncBusy = true;
resyncMessage = null;
try {
const r = await resyncManga(manga.id);
mangaOverride = r.manga;
const coverNote = r.cover_fetched
? ' Cover re-downloaded.'
: ' Cover unchanged.';
resyncMessage = {
kind: 'ok',
text: `Metadata ${r.metadata_status}.${coverNote}`
};
} catch (e) {
const msg = e instanceof ApiError ? e.message : (e as Error).message;
resyncMessage = { kind: 'err', text: msg };
} finally {
resyncBusy = false;
}
}
</script>
<svelte:head>
<title>{manga.title} — Mangalord</title>
<title>Mangalord | {manga.title}</title>
</svelte:head>
<article>
@@ -344,7 +377,34 @@
<UploadCloud size={16} aria-hidden="true" />
<span>Upload chapter</span>
</a>
{#if session.user.is_admin}
<button
type="button"
class="action"
onclick={forceResync}
disabled={resyncBusy}
title="Refetch metadata + cover from the crawler source"
data-testid="force-resync-manga"
>
<RefreshCw
size={16}
aria-hidden="true"
class={resyncBusy ? 'spin' : ''}
/>
<span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
</button>
{/if}
</div>
{#if resyncMessage}
<p
class="resync-msg"
class:err={resyncMessage.kind === 'err'}
role="status"
data-testid="force-resync-message"
>
{resyncMessage.text}
</p>
{/if}
{:else}
<a class="action" href="/login" data-testid="bookmark-signin">
Sign in to bookmark or collect
@@ -586,6 +646,29 @@
color: var(--text);
}
.resync-msg {
margin-top: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.resync-msg.err {
color: var(--danger);
}
:global(.spin) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.continue {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { fileUrl } from '$lib/api/client';
import { goto, invalidateAll } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { resyncChapter } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -15,6 +16,7 @@
import ScrollText from '@lucide/svelte/icons/scroll-text';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props();
const manga = $derived(data.manga);
@@ -27,8 +29,8 @@
const pageTitle = $derived(
chapter.title
? `${manga.title} Ch. ${chapter.number}: ${chapter.title}`
: `${manga.title} Ch. ${chapter.number}`
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
: `Mangalord | ${manga.title} · Ch. ${chapter.number}`
);
// Prev/next chapter computed from the chapter list. listChapters
@@ -256,6 +258,36 @@
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
});
// ---- Admin force resync (current chapter) ----
let resyncBusy = $state(false);
let resyncMessage = $state<{ kind: 'ok' | 'err'; text: string } | null>(null);
async function forceResync() {
if (!session.user?.is_admin || resyncBusy) return;
resyncBusy = true;
resyncMessage = null;
try {
const r = await resyncChapter(chapter.id);
if (r.outcome === 'fetched') {
resyncMessage = {
kind: 'ok',
text: `Refetched ${r.pages} page${r.pages === 1 ? '' : 's'}. Reloading…`
};
// Re-run all loaders for this route so the reader picks
// up the freshly-downloaded pages. The page.ts loader
// doesn't `depends()` on anything explicitly, so
// invalidateAll is the right brush here.
await invalidateAll();
} else {
resyncMessage = { kind: 'ok', text: 'No new pages — source had nothing fresh.' };
}
} catch (e) {
const msg = e instanceof ApiError ? e.message : (e as Error).message;
resyncMessage = { kind: 'err', text: msg };
} finally {
resyncBusy = false;
}
}
// ---- Reading progress tracking ----
//
// High-water mark seeded from the server: progress only ever moves
@@ -481,6 +513,23 @@
{/if}
</span>
{#if session.user?.is_admin}
<button
type="button"
class="reader-resync"
onclick={forceResync}
disabled={resyncBusy}
title={resyncMessage?.kind === 'err'
? resyncMessage.text
: 'Force refetch this chapter from the crawler source'}
aria-label="Force resync chapter"
data-testid="force-resync-chapter"
>
<RefreshCw size={16} aria-hidden="true" class={resyncBusy ? 'spin' : ''} />
<span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
</button>
{/if}
<button
type="button"
class="fullscreen-toggle"
@@ -494,6 +543,17 @@
</button>
</nav>
{#if resyncMessage}
<p
class="resync-toast"
class:err={resyncMessage.kind === 'err'}
role="status"
data-testid="force-resync-message"
>
{resyncMessage.text}
</p>
{/if}
<!--
Floating exit affordance — only rendered while focus mode is on.
Lives in the top-right corner with a low resting opacity so it
@@ -911,7 +971,8 @@
}
/* ===== Focus-mode controls ===== */
.fullscreen-toggle {
.fullscreen-toggle,
.reader-resync {
display: inline-flex;
align-items: center;
gap: var(--space-1);
@@ -925,12 +986,52 @@
font-size: var(--font-xs);
}
.fullscreen-toggle:hover {
.fullscreen-toggle:hover,
.reader-resync:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--text);
border-color: var(--primary);
}
.reader-resync:disabled {
opacity: 0.7;
cursor: progress;
}
.resync-toast {
position: fixed;
top: calc(var(--app-header-h) + var(--reader-nav-h, 48px) + var(--space-2));
right: var(--space-3);
z-index: 11;
margin: 0;
padding: var(--space-2) var(--space-3);
max-width: min(420px, calc(100vw - 2 * var(--space-3)));
background: var(--surface);
color: var(--text);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
font-size: var(--font-sm);
}
.resync-toast.err {
border-color: var(--danger);
color: var(--danger);
}
:global(.spin) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Small floating exit affordance — corner-pinned, low resting
opacity so it doesn't sit on the chapter image too aggressively
but is still findable without hover. */

View File

@@ -135,7 +135,7 @@
</script>
<svelte:head>
<title>Edit {manga.title} — Mangalord</title>
<title>Mangalord | Edit · {manga.title}</title>
</svelte:head>
<h1>Edit manga</h1>

View File

@@ -57,7 +57,7 @@
</script>
<svelte:head>
<title>Upload chapter {manga.title} — Mangalord</title>
<title>Mangalord | Upload chapter · {manga.title}</title>
</svelte:head>
<nav class="back">

View File

@@ -35,10 +35,6 @@
);
</script>
<svelte:head>
<title>Profile — Mangalord</title>
</svelte:head>
<header class="profile-header">
<h1>Profile</h1>
{#if !session.loaded}

View File

@@ -184,10 +184,6 @@
}
</script>
<svelte:head>
<title>Upload — Mangalord</title>
</svelte:head>
<h1>Create manga</h1>
{#if !session.loaded}

View File

@@ -21,6 +21,12 @@ export default defineConfig(({ mode }) => {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: false
},
resolve: {
// Use Svelte's browser entry under vitest so component tests can
// mount with @testing-library/svelte. The default (server entry)
// throws lifecycle_function_unavailable on mount().
conditions: mode === 'test' ? ['browser'] : []
}
};
});