Compare commits

...

6 Commits

Author SHA1 Message Date
MechaCat02
679abae736 feat(chapter): preserve source-site order in chapter list (0.52.0)
Some checks failed
deploy / test-backend (push) Failing after 11m48s
deploy / test-frontend (push) Successful in 9m45s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
The user-facing chapter list ordered by (number ASC, created_at ASC),
which broke the source site's order in two ways: non-numeric entries
("notice. : Officials") parsed to number=0 and clustered at the top,
even though the site placed them mid-list, and variants sharing a
number ("Ch.14 : PH" / "Ch.14 : Official") were torn apart by the
created_at tiebreak.

Capture each chapter's position in the source DOM as `source_index`
(0 = first = newest on this site) on every crawler sync, including the
UPDATE branch so a new chapter prepended on the source shifts every
existing row down by one on the next tick. The list query reverses
this with `ORDER BY source_index DESC NULLS LAST, number ASC,
created_at ASC` so the oldest chapter appears first, variants stay
adjacent in the order the site shows them, and non-numeric entries
land where the site placed them. User-uploaded chapters and pre-
migration rows keep their NULL source_index and fall through to the
prior number/created_at tiebreak via NULLS LAST.

The reader's client-side `[...chapters].sort((a,b) => a.number - b.number)`
is dropped; prev/next now walks the server-ordered array positionally
so it traverses variants and non-numeric entries in display order.

Existing data populates on the next cron tick or via admin force-resync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:25:09 +02:00
MechaCat02
b812c6d16c fix(reader): drop "Chapter N:" prefix from chapter title display (0.51.2)
The chapter list on the manga detail page, the reader's chapter-select
dropdown, the continuous-mode chapter bar, the browser tab title, and
the profile upload-history entries all prepended "Chapter {number}:"
in front of the crawled site title. Source titles already include
"Ch.N" themselves and the manga page renders chapters inside an <ol>,
so the prefix duplicated information the user could already see.

A small chapterLabel(c) helper in $lib/api/chapters returns the site
title as-is, falling back to "Chapter {number}" only when the
crawler captured an empty title (link/option stays non-empty). The
five render sites now call it. The previous-/next-chapter nav
buttons still read "Previous chapter (Ch. N)" / "Next chapter (Ch. N)"
since those are wayfinding labels, not title display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:22:17 +02:00
MechaCat02
e93eec89e5 fix(crawler): queue chapter content in ascending number order (0.51.1)
Both enqueue paths now order by chapters.number so the cron tick and the
bookmark hook insert jobs from chapter 1 upward instead of source-discovery
or random-UUID order. The lease query tiebreaks on created_at so jobs
sharing a batch's scheduled_at come off the queue in insertion order,
propagating the enqueue intent through to dequeue. Concurrent workers
and per-CDN latency can still drift actual completion order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 21:13:51 +02:00
MechaCat02
8818c890c5 feat(reader): chapter select dropdown for direct chapter jumps (0.51.0)
Adds a chapter `<select>` to the reader's top nav listing every chapter
of the current manga, defaulting to the open chapter; picking another
entry navigates straight to it without going back to the manga detail
page. Options use the "Ch. N — Title" form to match the existing
chapter tile and prev/next buttons in the reader bar.

Covered by a new Playwright spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 07:09:30 +02:00
MechaCat02
c134bdbbde 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 22:00:09 +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
46 changed files with 2765 additions and 99 deletions

2
backend/Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.48.0" version = "0.52.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"
@@ -57,3 +57,13 @@ http-body-util = "0.1"
mime = "0.3" mime = "0.3"
futures-util = "0.3" futures-util = "0.3"
tokio = { version = "1", features = ["test-util"] } 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

@@ -0,0 +1,18 @@
-- Capture each chapter's position in the source site's chapter list so
-- the user-facing list can preserve site order: variants of the same
-- chapter number (e.g. "Ch.14 : PH" next to "Ch.14 : Official") stay
-- adjacent, and non-numeric entries like "notice. : Officials" land
-- where the site placed them rather than clustering at the top under
-- number = 0.
--
-- Lower source_index = closer to the top of the source DOM = newer
-- chapter on this site (it renders newest-first). The list query
-- reverses this with ORDER BY source_index DESC so the oldest chapter
-- appears first in our UI.
--
-- NULL is the sentinel for user-uploaded chapters (no source row) and
-- for crawled rows that pre-date this migration. The list query keeps
-- the existing (number, created_at) tiebreak via NULLS LAST so those
-- fall through to the prior behaviour until the next crawler tick
-- populates the column.
ALTER TABLE chapters ADD COLUMN source_index INTEGER;

View File

@@ -5,6 +5,7 @@
//! `crate::auth::extractor::RequireAdmin`). //! `crate::auth::extractor::RequireAdmin`).
pub mod mangas; pub mod mangas;
pub mod resync;
pub mod system; pub mod system;
pub mod users; pub mod users;
@@ -16,5 +17,6 @@ pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.merge(users::routes()) .merge(users::routes())
.merge(mangas::routes()) .merge(mangas::routes())
.merge(resync::routes())
.merge(system::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::jobs::JobPayload;
use crate::crawler::pipeline::{self, MetadataStats}; use crate::crawler::pipeline::{self, MetadataStats};
use crate::crawler::rate_limit::HostRateLimiters; use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::resync::{RealResyncService, ResyncService};
use crate::crawler::safety::DownloadAllowlist; use crate::crawler::safety::DownloadAllowlist;
use crate::crawler::session; use crate::crawler::session;
use crate::repo; use crate::repo;
@@ -39,6 +40,12 @@ pub struct AppState {
/// One instance per AppState so tests stay isolated across the /// One instance per AppState so tests stay isolated across the
/// same process. /// same process.
pub auth_limiter: Arc<AuthRateLimiter>, 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; /// 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 storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
let daemon = if config.crawler.daemon_enabled { let (daemon, resync) = if config.crawler.daemon_enabled {
Some(spawn_crawler_daemon(db.clone(), Arc::clone(&storage), &config.crawler).await?) let spawned = spawn_crawler_daemon(db.clone(), Arc::clone(&storage), &config.crawler).await?;
(Some(spawned.handle), Some(spawned.resync))
} else { } else {
tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)"); tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)");
None (None, None)
}; };
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit)); 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(), auth: config.auth.clone(),
upload: config.upload.clone(), upload: config.upload.clone(),
auth_limiter, auth_limiter,
resync,
}; };
let router = router(state).layer(cors_layer(&config.cors_allowed_origins)); let router = router(state).layer(cors_layer(&config.cors_allowed_origins));
Ok(AppHandle { router, daemon }) 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( async fn spawn_crawler_daemon(
db: PgPool, db: PgPool,
storage: Arc<dyn Storage>, storage: Arc<dyn Storage>,
cfg: &CrawlerConfig, cfg: &CrawlerConfig,
) -> anyhow::Result<daemon::DaemonHandle> { ) -> anyhow::Result<SpawnedDaemon> {
// Reqwest client with cookie jar pre-seeded so CDN image fetches // Reqwest client with cookie jar pre-seeded so CDN image fetches
// include PHPSESSID. Same shape as bin/crawler.rs main(). // include PHPSESSID. Same shape as bin/crawler.rs main().
let cookie_jar = Arc::new(reqwest::cookie::Jar::default()); 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 { 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), browser_manager: Arc::clone(&browser_manager),
db: db.clone(), db: db.clone(),
storage: Arc::clone(&storage), 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 // 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; 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 result
} }
} }

View File

@@ -104,6 +104,12 @@ pub async fn enqueue(pool: &PgPool, payload: &JobPayload) -> sqlx::Result<Enqueu
/// ///
/// `kind_filter` matches against `payload->>'kind'`; `None` means /// `kind_filter` matches against `payload->>'kind'`; `None` means
/// any kind. /// any kind.
///
/// Ties on `scheduled_at` (the common case: a cron batch enqueues
/// everything with the same default `now()`) break by `created_at`, so
/// jobs come off the queue in insertion order. The enqueue paths insert
/// chapter-content jobs in ascending `chapters.number` order, so this
/// tiebreaker is what propagates that intent through to dequeue.
pub async fn lease( pub async fn lease(
pool: &PgPool, pool: &PgPool,
kind_filter: Option<&str>, kind_filter: Option<&str>,
@@ -118,7 +124,7 @@ pub async fn lease(
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now())) WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
AND scheduled_at <= now() AND scheduled_at <= now()
AND ($1::text IS NULL OR payload->>'kind' = $1) AND ($1::text IS NULL OR payload->>'kind' = $1)
ORDER BY scheduled_at ORDER BY scheduled_at, created_at
LIMIT $2 LIMIT $2
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )

View File

@@ -23,6 +23,7 @@ pub mod jobs;
pub mod nav; pub mod nav;
pub mod pipeline; pub mod pipeline;
pub mod rate_limit; pub mod rate_limit;
pub mod resync;
pub mod safety; pub mod safety;
pub mod session; pub mod session;
pub mod source; 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::rate_limit::HostRateLimiters;
use crate::crawler::safety::{fetch_bytes_capped, looks_like_image, DownloadAllowlist}; use crate::crawler::safety::{fetch_bytes_capped, looks_like_image, DownloadAllowlist};
use crate::crawler::source::target::TargetSource; 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;
use crate::repo::crawler::UpsertStatus; use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage; use crate::storage::Storage;
@@ -429,8 +429,8 @@ pub async fn enqueue_bookmarked_pending(pool: &PgPool) -> anyhow::Result<Enqueue
AND cj.state = 'dead' AND cj.state = 'dead'
AND cj.updated_at > now() - ($1::bigint || ' days')::interval AND cj.updated_at > now() - ($1::bigint || ' days')::interval
) )
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.created_at GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.number, c.created_at
ORDER BY c.manga_id, c.created_at ASC ORDER BY c.manga_id, c.number ASC, c.created_at ASC
"#, "#,
) )
.bind(CHAPTER_DEAD_QUARANTINE_DAYS) .bind(CHAPTER_DEAD_QUARANTINE_DAYS)
@@ -471,7 +471,7 @@ pub async fn enqueue_pending_for_manga(
) -> anyhow::Result<EnqueueSummary> { ) -> anyhow::Result<EnqueueSummary> {
let rows: Vec<(String, Uuid, String)> = sqlx::query_as( let rows: Vec<(String, Uuid, String)> = sqlx::query_as(
r#" r#"
SELECT DISTINCT cs.source_id, c.id AS chapter_id, cs.source_chapter_key SELECT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
FROM chapters c FROM chapters c
JOIN chapter_sources cs ON cs.chapter_id = c.id JOIN chapter_sources cs ON cs.chapter_id = c.id
WHERE c.manga_id = $1 WHERE c.manga_id = $1
@@ -484,7 +484,8 @@ pub async fn enqueue_pending_for_manga(
AND cj.state = 'dead' AND cj.state = 'dead'
AND cj.updated_at > now() - ($2::bigint || ' days')::interval AND cj.updated_at > now() - ($2::bigint || ' days')::interval
) )
ORDER BY cs.source_id, c.id GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.number, c.created_at
ORDER BY c.number ASC, c.created_at ASC, cs.source_id
"#, "#,
) )
.bind(manga_id) .bind(manga_id)
@@ -523,12 +524,133 @@ pub struct EnqueueSummary {
pub failed: usize, 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 /// Download a cover image and persist its storage path. Local to the
/// pipeline because the CLI still calls it from its inline chapter-content /// 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 /// loop; once the worker pool fully replaces that path we can fold this
/// into `pipeline` proper. /// into `pipeline` proper.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn download_and_store_cover( pub(crate) async fn download_and_store_cover(
db: &PgPool, db: &PgPool,
storage: &dyn Storage, storage: &dyn Storage,
http: &reqwest::Client, 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), PayloadTooLarge(String),
#[error("unsupported media type: {0}")] #[error("unsupported media type: {0}")]
UnsupportedMediaType(String), 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). /// 429 with an optional `Retry-After` header value (in seconds).
#[error("too many requests")] #[error("too many requests")]
TooManyRequests { TooManyRequests {
@@ -56,6 +61,7 @@ impl AppError {
AppError::Conflict(_) => "conflict", AppError::Conflict(_) => "conflict",
AppError::PayloadTooLarge(_) => "payload_too_large", AppError::PayloadTooLarge(_) => "payload_too_large",
AppError::UnsupportedMediaType(_) => "unsupported_media_type", AppError::UnsupportedMediaType(_) => "unsupported_media_type",
AppError::ServiceUnavailable(_) => "service_unavailable",
AppError::TooManyRequests { .. } => "too_many_requests", AppError::TooManyRequests { .. } => "too_many_requests",
AppError::ValidationFailed { .. } => "validation_failed", AppError::ValidationFailed { .. } => "validation_failed",
AppError::Database(sqlx::Error::RowNotFound) => "not_found", AppError::Database(sqlx::Error::RowNotFound) => "not_found",
@@ -85,6 +91,9 @@ impl IntoResponse for AppError {
AppError::UnsupportedMediaType(msg) => { AppError::UnsupportedMediaType(msg) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None) (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None)
} }
AppError::ServiceUnavailable(msg) => {
(StatusCode::SERVICE_UNAVAILABLE, msg.clone(), None)
}
AppError::TooManyRequests { retry_after_secs } => { AppError::TooManyRequests { retry_after_secs } => {
// Emit `Retry-After: N` (RFC 6585 §4) so a well-behaved // Emit `Retry-After: N` (RFC 6585 §4) so a well-behaved
// client can back off correctly. Done by building the // client can back off correctly. Done by building the

View File

@@ -12,15 +12,20 @@ pub async fn list_for_manga(
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> AppResult<Vec<Chapter>> { ) -> AppResult<Vec<Chapter>> {
// Secondary sort by created_at gives duplicate-numbered chapters // Display order = source-site order reversed. The crawler stamps
// (multiple uploaders/translations of the same number) a stable // `source_index` = position in the source DOM (0 = first = newest
// order in lists and prev/next reader navigation. // on this site, see migration 0021), so DESC puts the oldest
// chapter first and keeps the site's variant grouping and the
// placement of non-numeric entries (e.g. "notice. : Officials")
// intact. NULLS LAST keeps user-uploaded chapters (no source row)
// and rows that pre-date the migration below crawled rows; the
// (number, created_at) tail then orders them deterministically.
let rows = sqlx::query_as::<_, Chapter>( let rows = sqlx::query_as::<_, Chapter>(
r#" r#"
SELECT id, manga_id, number, title, page_count, created_at SELECT id, manga_id, number, title, page_count, created_at
FROM chapters FROM chapters
WHERE manga_id = $1 WHERE manga_id = $1
ORDER BY number ASC, created_at ASC ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
"#, "#,
) )

View File

@@ -352,7 +352,14 @@ pub async fn sync_manga_chapters(
.map(|c| c.source_chapter_key.clone()) .map(|c| c.source_chapter_key.clone())
.collect(); .collect();
for c in chapters { for (idx, c) in chapters.iter().enumerate() {
// `source_index` captures the chapter's position in the source
// DOM (0 = first = newest on this site) so the list query can
// reverse it for the user-facing list — see migration 0021.
// Every sync overwrites the value on both branches, so a new
// chapter inserted at the top of the source shifts every other
// row down by one on the next tick.
let source_index = idx as i32;
// Lookup is constrained by manga_id (via the chapters join) so a // Lookup is constrained by manga_id (via the chapters join) so a
// source whose chapter slugs collide across mangas (e.g. // source whose chapter slugs collide across mangas (e.g.
// "chapter-1" appearing under two different mangas) attributes // "chapter-1" appearing under two different mangas) attributes
@@ -382,14 +389,15 @@ pub async fn sync_manga_chapters(
// identity is the UUID, not the number. // identity is the UUID, not the number.
let (chapter_id,): (Uuid,) = sqlx::query_as( let (chapter_id,): (Uuid,) = sqlx::query_as(
r#" r#"
INSERT INTO chapters (manga_id, number, title, page_count) INSERT INTO chapters (manga_id, number, title, page_count, source_index)
VALUES ($1, $2, $3, 0) VALUES ($1, $2, $3, 0, $4)
RETURNING id RETURNING id
"#, "#,
) )
.bind(manga_id) .bind(manga_id)
.bind(c.number) .bind(c.number)
.bind(c.title.as_deref()) .bind(c.title.as_deref())
.bind(source_index)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
sqlx::query( sqlx::query(
@@ -408,8 +416,11 @@ pub async fn sync_manga_chapters(
diff.new += 1; diff.new += 1;
} }
Some((chapter_id,)) => { Some((chapter_id,)) => {
sqlx::query("UPDATE chapters SET title = $1 WHERE id = $2") sqlx::query(
"UPDATE chapters SET title = $1, source_index = $2 WHERE id = $3",
)
.bind(c.title.as_deref()) .bind(c.title.as_deref())
.bind(source_index)
.bind(chapter_id) .bind(chapter_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -542,6 +553,51 @@ pub async fn mark_run_completed(pool: &PgPool, source_id: &str) -> sqlx::Result<
Ok(()) 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 /// Read the recovery flag for `source_id`. A missing row OR an
/// unparseable value reads as `true` ("clean") — the former covers the /// unparseable value reads as `true` ("clean") — the former covers the
/// first-ever run on a virgin DB (no recovery needed), the latter /// 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, auth,
upload: UploadConfig::default(), upload: UploadConfig::default(),
auth_limiter, auth_limiter,
resync: None,
}; };
let app = Router::new() let app = Router::new()
.nest("/api/v1", api::routes()) .nest("/api/v1", api::routes())

View File

@@ -74,6 +74,10 @@ fn harness_with_auth_config(
max_file_bytes: 256 * 1024, max_file_bytes: 256 * 1024,
}, },
auth_limiter, 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 } 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) 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 /// 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 /// assert that handlers roll their DB writes back when storage errors
/// mid-upload. Reads and other operations delegate to `inner`. /// mid-upload. Reads and other operations delegate to `inner`.

View File

@@ -517,3 +517,132 @@ async fn enqueue_bookmarked_pending_resumes_after_quarantine_expires(pool: PgPoo
); );
} }
/// Helper: insert a chapter with the given `number` and a non-dropped
/// source row, returning the chapter id. Used by the ordering tests so
/// the setup boilerplate doesn't drown the assertion.
async fn insert_pending_chapter(
pool: &PgPool,
manga_id: Uuid,
number: i32,
source_chapter_key: &str,
) -> Uuid {
let chapter_id: Uuid = sqlx::query_scalar(
"INSERT INTO chapters (manga_id, number, page_count) VALUES ($1, $2, 0) RETURNING id",
)
.bind(manga_id)
.bind(number)
.fetch_one(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url) \
VALUES ($1, $2, $3, $4)",
)
.bind("target")
.bind(source_chapter_key)
.bind(chapter_id)
.bind(format!("https://example.com/{source_chapter_key}"))
.execute(pool)
.await
.unwrap();
chapter_id
}
#[sqlx::test(migrations = "./migrations")]
async fn enqueue_bookmarked_pending_queues_chapters_in_ascending_number_order(pool: PgPool) {
// Insert chapters with `number` values 3, 1, 2 in that insertion
// order — so `created_at` order (the previous tiebreaker) does NOT
// match number order. After enqueue + lease, the worker should see
// chapters 1, 2, 3 in that sequence.
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id",
)
.bind("alice")
.bind("not-a-real-hash")
.fetch_one(&pool)
.await
.unwrap();
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind("Test")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
)
.bind("target")
.bind("Target")
.bind("https://example.com")
.execute(&pool)
.await
.unwrap();
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
sqlx::query("INSERT INTO bookmarks (user_id, manga_id) VALUES ($1, $2)")
.bind(user_id)
.bind(manga_id)
.execute(&pool)
.await
.unwrap();
let summary = pipeline::enqueue_bookmarked_pending(&pool).await.unwrap();
assert_eq!(summary.inserted, 3);
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
.await
.unwrap();
let leased_chapter_ids: Vec<Uuid> = leases
.iter()
.map(|l| match &l.payload {
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
other => panic!("unexpected payload kind: {other:?}"),
})
.collect();
assert_eq!(
leased_chapter_ids,
vec![c1, c2, c3],
"chapters must be leased in ascending chapter-number order, not insertion order"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn enqueue_pending_for_manga_queues_chapters_in_ascending_number_order(pool: PgPool) {
// Same scenario as above but exercising the bookmark-create hook path
// (`enqueue_pending_for_manga`) which has its own ORDER BY.
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind("Test")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
)
.bind("target")
.bind("Target")
.bind("https://example.com")
.execute(&pool)
.await
.unwrap();
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
let summary = pipeline::enqueue_pending_for_manga(&pool, manga_id)
.await
.unwrap();
assert_eq!(summary.inserted, 3);
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
.await
.unwrap();
let leased_chapter_ids: Vec<Uuid> = leases
.iter()
.map(|l| match &l.payload {
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
other => panic!("unexpected payload kind: {other:?}"),
})
.collect();
assert_eq!(leased_chapter_ids, vec![c1, c2, c3]);
}

View File

@@ -531,6 +531,89 @@ async fn reap_done_deletes_old_rows_keeps_fresh(pool: PgPool) {
assert_eq!(remaining, vec![fresh_id], "only fresh row remains"); assert_eq!(remaining, vec![fresh_id], "only fresh row remains");
} }
#[sqlx::test(migrations = "./migrations")]
async fn lease_ties_on_scheduled_at_break_by_created_at(pool: PgPool) {
// Locks in the tiebreaker that lets enqueue order survive the lease
// step: when many jobs share `scheduled_at` (the common cron-batch
// case), the worker must pick the earliest-inserted row, not whatever
// Postgres returns in heap order. The enqueue path inserts chapters
// in chapter-number order, so this tiebreaker is what makes "queue
// in rising order" observable at the dequeue side too.
let a = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
let b = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
let c = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
// Pin `scheduled_at` to a single literal instant (shared across all
// three rows — `now()` would yield a different microsecond per UPDATE
// and make scheduled_at the actual sort key). Reverse `created_at`
// against insertion order so heap order would give the wrong answer.
let shared_scheduled = chrono::Utc::now() - chrono::Duration::hours(1);
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(a)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(10))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(b)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(20))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(c)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(30))
.execute(&pool)
.await
.unwrap();
let leases = jobs::lease(&pool, None, 10, Duration::from_secs(60))
.await
.unwrap();
let order: Vec<Uuid> = leases.iter().map(|l| l.id).collect();
assert_eq!(
order,
vec![c, b, a],
"lease must return jobs in created_at order when scheduled_at ties"
);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn reap_done_zero_is_a_no_op(pool: PgPool) { async fn reap_done_zero_is_a_no_op(pool: PgPool) {
let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4())) let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))

View File

@@ -6,6 +6,7 @@
use mangalord::crawler::source::{SourceChapterRef, SourceManga}; use mangalord::crawler::source::{SourceChapterRef, SourceManga};
use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus}; use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus};
use mangalord::repo::chapter as chapter_repo;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -829,6 +830,107 @@ async fn sync_tags_garbage_collects_orphan_user_attachments(pool: PgPool) {
assert_eq!(orphan_rows, 0, "orphan user-attached tag should be reaped"); 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")] #[sqlx::test(migrations = "./migrations")]
async fn re_appearing_manga_clears_dropped_at(pool: PgPool) { async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example") crawler::ensure_source(&pool, "target", "T", "https://x.example")
@@ -860,3 +962,261 @@ async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
assert!(dropped.0.is_none()); assert!(dropped.0.is_none());
assert_eq!(dropped.1, up.manga_id); assert_eq!(dropped.1, up.manga_id);
} }
// ---- source_index: site-order preservation ----
//
// The user-facing chapter list reverses the source-site order so that
// the oldest chapter appears first. The crawler records each row's DOM
// position in `chapters.source_index` (0 = first in source DOM = newest
// on this site) on every sync; the list query orders by source_index
// DESC NULLS LAST, falling through to number/created_at for rows with
// no source row (e.g. user uploads).
#[sqlx::test(migrations = "./migrations")]
async fn source_index_set_on_insert_matches_dom_order(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
let chapters = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 30,
title: Some("Ch.30".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 29,
title: Some("Ch.29".into()),
url: "https://x.example/foo/b".into(),
},
SourceChapterRef {
source_chapter_key: "c".into(),
number: 28,
title: Some("Ch.28".into()),
url: "https://x.example/foo/c".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
.await
.unwrap();
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
"SELECT cs.source_chapter_key, c.source_index \
FROM chapters c \
JOIN chapter_sources cs ON cs.chapter_id = c.id \
WHERE c.manga_id = $1 \
ORDER BY cs.source_chapter_key",
)
.bind(up.manga_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
rows,
vec![
("a".to_string(), Some(0)),
("b".to_string(), Some(1)),
("c".to_string(), Some(2)),
],
"source_index reflects enumerate() position in the input slice",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn source_index_rewritten_on_resync_when_new_chapter_prepended(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
let first = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 2,
title: Some("Ch.2".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &first)
.await
.unwrap();
// Second sync: a brand-new chapter appears at the top of the source
// (newest first on the site). All existing rows must shift their
// source_index down by one so the display order stays correct.
let second = vec![
SourceChapterRef {
source_chapter_key: "new".into(),
number: 3,
title: Some("Ch.3".into()),
url: "https://x.example/foo/new".into(),
},
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 2,
title: Some("Ch.2".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &second)
.await
.unwrap();
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
"SELECT cs.source_chapter_key, c.source_index \
FROM chapters c \
JOIN chapter_sources cs ON cs.chapter_id = c.id \
WHERE c.manga_id = $1 \
ORDER BY cs.source_chapter_key",
)
.bind(up.manga_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
rows,
vec![
("a".to_string(), Some(1)),
("b".to_string(), Some(2)),
("new".to_string(), Some(0)),
],
"new chapter takes index 0, existing rows shift down on UPDATE",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_for_manga_returns_source_order_reversed(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
// Site DOM order (top-down = newest-first):
// ch11 (number = 11)
// notice (number = 0, non-numeric label on the site)
// ch10 (number = 10)
// Numbers deliberately disagree with DOM order: a number-based sort
// would put notice first, but the site places it between ch10 and
// ch11. Reversed-DOM display should yield [ch10, notice, ch11].
let chapters = vec![
SourceChapterRef {
source_chapter_key: "ch11".into(),
number: 11,
title: Some("Ch.11 : Official".into()),
url: "https://x.example/foo/11".into(),
},
SourceChapterRef {
source_chapter_key: "notice".into(),
number: 0,
title: Some("notice. : Officials".into()),
url: "https://x.example/foo/notice".into(),
},
SourceChapterRef {
source_chapter_key: "ch10".into(),
number: 10,
title: Some("Ch.10 : Official".into()),
url: "https://x.example/foo/10".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
.await
.unwrap();
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
.await
.unwrap();
let keys: Vec<String> = listed
.iter()
.map(|c| c.title.clone().unwrap_or_default())
.collect();
assert_eq!(
keys,
vec![
"Ch.10 : Official".to_string(),
"notice. : Officials".to_string(),
"Ch.11 : Official".to_string(),
],
"list returns chapters in reversed source-DOM order, so the \
oldest appears first and non-numeric entries land where the \
site placed them",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
// Crawled chapters get source_index 0 and 1; the upload path leaves
// it NULL. NULLS LAST plus the (number, created_at) tail means the
// upload sits after both crawled rows even though its number is in
// the middle.
let crawled = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 3,
title: Some("Ch.3".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &crawled)
.await
.unwrap();
chapter_repo::create(&pool, up.manga_id, 2, Some("User upload Ch.2"), None)
.await
.unwrap();
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
.await
.unwrap();
let titles: Vec<String> = listed
.iter()
.map(|c| c.title.clone().unwrap_or_default())
.collect();
assert_eq!(
titles,
vec![
"Ch.3".to_string(),
"Ch.1".to_string(),
"User upload Ch.2".to_string(),
],
"crawled rows ordered by reversed source_index; user upload \
(NULL source_index) falls through to the end",
);
}

View File

@@ -10,6 +10,15 @@ import { test, expect, type Page } from '@playwright/test';
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
async function mockAnonymous(page: Page) { 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 page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({ await route.fulfill({
status: 401, status: 401,
@@ -69,3 +78,53 @@ test('search updates the manga list', async ({ page }) => {
await expect(page.getByTestId('manga-list')).toContainText('Berserk'); await expect(page.getByTestId('manga-list')).toContainText('Berserk');
expect(lastSearch).toBe('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

@@ -0,0 +1,167 @@
import { test, expect, type Page } from '@playwright/test';
const mangaId = '33333333-3333-3333-3333-333333333333';
const chapter1Id = 'c1111111-3333-3333-3333-333333333333';
const chapter2Id = 'c2222222-3333-3333-3333-333333333333';
const chapter3Id = 'c3333333-3333-3333-3333-333333333333';
const mangaFixture = {
id: mangaId,
title: 'Vinland Saga',
author: 'Makoto Yukimura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
const chaptersFixture = [
{
id: chapter1Id,
manga_id: mangaId,
number: 1,
title: 'Somewhere, Not Here',
page_count: 1,
created_at: '2026-01-01T00:00:00Z'
},
{
id: chapter2Id,
manga_id: mangaId,
number: 2,
title: null,
page_count: 1,
created_at: '2026-01-02T00:00:00Z'
},
{
id: chapter3Id,
manga_id: mangaId,
number: 3,
title: 'Sword Dance',
page_count: 1,
created_at: '2026-01-03T00:00:00Z'
}
];
function pageFixture(chapterId: string) {
return [
{
id: `p1111111-${chapterId.slice(1, 8)}-3333-3333-333333333333`,
chapter_id: chapterId,
page_number: 1,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
content_type: 'image/png'
}
];
}
async function mockReaderApis(page: Page) {
// Force public mode so the layout doesn't bounce anonymous visitors
// to /login (the dev backend on this machine runs with
// PRIVATE_MODE=true, which the layout's universal load respects).
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route(new RegExp(`/api/v1/mangas/${mangaId}/chapters(\\?.*)?$`), (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: chaptersFixture,
page: { limit: 200, offset: 0, total: chaptersFixture.length }
})
})
);
for (const c of chaptersFixture) {
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${c.id}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(c)
})
);
await page.route(
`**/api/v1/mangas/${mangaId}/chapters/${c.id}/pages`,
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pages: pageFixture(c.id) })
})
);
}
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
);
}
test('reader chapter select lists every chapter with the manga-detail-style label', async ({
page
}) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapter2Id}`);
const select = page.getByTestId('reader-chapter-select');
await expect(select).toBeVisible();
// The current chapter is preselected.
await expect(select).toHaveValue(chapter2Id);
// Each chapter rendered as "Ch. N — Title" (or "Ch. N" when title is null),
// in ascending number order — matching the prev/next sort.
const labels = await select.locator('option').allTextContents();
expect(labels.map((l) => l.trim())).toEqual([
'Ch. 1 — Somewhere, Not Here',
'Ch. 2',
'Ch. 3 — Sword Dance'
]);
});
test('choosing a chapter from the select navigates to that chapter', async ({ page }) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapter1Id}`);
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter1Id);
await page.getByTestId('reader-chapter-select').selectOption(chapter3Id);
await expect(page).toHaveURL(
new RegExp(`/manga/${mangaId}/chapter/${chapter3Id}$`)
);
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter3Id);
});

View File

@@ -120,7 +120,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) =
await expect(page.getByTestId('manga-title')).toHaveText('Berserk'); await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura'); await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
await expect(page.getByTestId('manga-cover')).toBeVisible(); await expect(page.getByTestId('manga-cover')).toBeVisible();
await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1'); await expect(page.getByTestId('chapter-list')).toContainText('The Brand');
await expect(page.getByTestId('bookmark-signin')).toBeVisible(); await expect(page.getByTestId('bookmark-signin')).toBeVisible();
}); });

View File

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

View File

@@ -14,7 +14,9 @@ import {
createAdminUser, createAdminUser,
listAdminMangas, listAdminMangas,
listAdminChapters, listAdminChapters,
getSystemStats getSystemStats,
resyncManga,
resyncChapter
} from './admin'; } from './admin';
function ok(body: unknown, status = 200): Response { function ok(body: unknown, status = 200): Response {
@@ -242,4 +244,88 @@ describe('admin api client', () => {
const s = await getSystemStats(); const s = await getSystemStats();
expect(s.disk).toBeNull(); 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 { request, type Page } from './client';
import type { User } from './auth'; import type { User } from './auth';
import type { MangaDetail } from './mangas';
import type { Chapter } from './chapters';
// ---- users ----------------------------------------------------------------- // ---- users -----------------------------------------------------------------
@@ -176,3 +178,39 @@ export type SystemStats = {
export async function getSystemStats(): Promise<SystemStats> { export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>('/v1/admin/system'); 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

@@ -11,7 +11,8 @@ import {
listChapters, listChapters,
getChapter, getChapter,
getChapterPages, getChapterPages,
createChapter createChapter,
chapterLabel
} from './chapters'; } from './chapters';
function ok(body: unknown): Response { function ok(body: unknown): Response {
@@ -129,6 +130,18 @@ describe('chapters api client', () => {
} }
}); });
describe('chapterLabel', () => {
it('returns the site title verbatim when present', () => {
expect(chapterLabel({ number: 7, title: 'Ch.7 : Official' })).toBe(
'Ch.7 : Official'
);
});
it('falls back to "Chapter {number}" when title is null', () => {
expect(chapterLabel({ number: 3, title: null })).toBe('Chapter 3');
});
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => { it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
ok({ ok({

View File

@@ -14,6 +14,10 @@ export type ChaptersPage = {
page: Page; page: Page;
}; };
export function chapterLabel(c: Pick<Chapter, 'number' | 'title'>): string {
return c.title ?? `Chapter ${c.number}`;
}
export type ListOptions = { export type ListOptions = {
limit?: number; limit?: number;
offset?: number; offset?: number;

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"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { logout } from '$lib/api/auth'; import { logout } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte'; import { authConfig } from '$lib/auth-config.svelte';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
@@ -18,6 +19,32 @@
let loggingOut = $state(false); let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state(); 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 // Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout // the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial // load (client-side nav). The first run also covers initial
@@ -78,6 +105,10 @@
} }
</script> </script>
<svelte:head>
<title>{layoutTitle}</title>
</svelte:head>
<header bind:this={headerEl}> <header bind:this={headerEl}>
<nav aria-label="primary"> <nav aria-label="primary">
<a class="brand" href="/">Mangalord</a> <a class="brand" href="/">Mangalord</a>

View File

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

View File

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

View File

@@ -1,15 +1,33 @@
<script lang="ts"> <script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data } = $props(); let { data } = $props();
const author = $derived(data.author); const author = $derived(data.author);
const mangas = $derived(data.mangas); const mangas = $derived(data.mangas);
const total = $derived(data.total); 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> </script>
<svelte:head> <svelte:head>
<title>{author.name} — Mangalord</title> <title>Mangalord | {author.name}</title>
</svelte:head> </svelte:head>
<nav class="back"> <nav class="back">
@@ -34,7 +52,7 @@
{:else} {:else}
{#if total != null} {#if total != null}
<p class="meta" data-testid="author-shown-of-total"> <p class="meta" data-testid="author-shown-of-total">
Showing {mangas.length} of {total} Showing {rangeStart}{rangeEnd} of {total}
</p> </p>
{/if} {/if}
<ul class="manga-grid" data-testid="author-manga-list"> <ul class="manga-grid" data-testid="author-manga-list">
@@ -42,6 +60,12 @@
<MangaCard manga={m} testid={`author-manga-${m.id}`} /> <MangaCard manga={m} testid={`author-manga-${m.id}`} />
{/each} {/each}
</ul> </ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="author-pager"
/>
{/if} {/if}
<style> <style>

View File

@@ -5,13 +5,27 @@ import type { PageLoad } from './$types';
export const ssr = false; 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 { try {
const [author, mangas] = await Promise.all([ const [author, mangas] = await Promise.all([
getAuthor(params.id), 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) { } catch (e) {
// 404 surfaces as a real SvelteKit error so the framework shell // 404 surfaces as a real SvelteKit error so the framework shell
// renders the standard not-found page instead of the route's // renders the standard not-found page instead of the route's

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
<script lang="ts"> <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 { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
import { import {
attachTag, attachTag,
detachTag, detachTag,
type AuthorRef, type AuthorRef,
type GenreRef, type GenreRef,
type MangaDetail,
type TagRef type TagRef
} from '$lib/api/mangas'; } from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { listTags, type Tag } from '$lib/api/tags'; import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte'; import Chip from '$lib/components/Chip.svelte';
@@ -16,9 +19,15 @@
import FolderPlus from '@lucide/svelte/icons/folder-plus'; import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Pencil from '@lucide/svelte/icons/pencil'; import Pencil from '@lucide/svelte/icons/pencil';
import UploadCloud from '@lucide/svelte/icons/upload-cloud'; import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props(); 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 chapters = $derived(data.chapters);
const readProgress = $derived(data.readProgress); const readProgress = $derived(data.readProgress);
/** Chapter row from the local chapters list when present (so we /** Chapter row from the local chapters list when present (so we
@@ -37,6 +46,11 @@
continueChapter?.number ?? readProgress?.chapter_number ?? null continueChapter?.number ?? readProgress?.chapter_number ?? null
); );
const continueChapterTitle = $derived(continueChapter?.title ?? null); const continueChapterTitle = $derived(continueChapter?.title ?? null);
const continueLabel = $derived(
continueChapterNumber != null
? chapterLabel({ number: continueChapterNumber, title: continueChapterTitle })
: null
);
const authors = $derived<AuthorRef[]>(manga.authors); const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres); const genres = $derived<GenreRef[]>(manga.genres);
@@ -171,10 +185,35 @@
const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing'); const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing');
let collectionModalOpen = $state(false); 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> </script>
<svelte:head> <svelte:head>
<title>{manga.title} — Mangalord</title> <title>Mangalord | {manga.title}</title>
</svelte:head> </svelte:head>
<article> <article>
@@ -344,7 +383,34 @@
<UploadCloud size={16} aria-hidden="true" /> <UploadCloud size={16} aria-hidden="true" />
<span>Upload chapter</span> <span>Upload chapter</span>
</a> </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> </div>
{#if resyncMessage}
<p
class="resync-msg"
class:err={resyncMessage.kind === 'err'}
role="status"
data-testid="force-resync-message"
>
{resyncMessage.text}
</p>
{/if}
{:else} {:else}
<a class="action" href="/login" data-testid="bookmark-signin"> <a class="action" href="/login" data-testid="bookmark-signin">
Sign in to bookmark or collect Sign in to bookmark or collect
@@ -371,7 +437,7 @@
> >
<span class="continue-label">Continue reading</span> <span class="continue-label">Continue reading</span>
<span class="continue-target"> <span class="continue-target">
Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if} {continueLabel}
{#if readProgress && readProgress.page > 1} {#if readProgress && readProgress.page > 1}
— page {readProgress.page} — page {readProgress.page}
{/if} {/if}
@@ -385,7 +451,7 @@
{#each chapters as c (c.id)} {#each chapters as c (c.id)}
<li> <li>
<a href="/manga/{manga.id}/chapter/{c.id}"> <a href="/manga/{manga.id}/chapter/{c.id}">
Chapter {c.number}{#if c.title}: {c.title}{/if} {chapterLabel(c)}
</a> </a>
<span class="pages">({c.page_count} pages)</span> <span class="pages">({c.page_count} pages)</span>
</li> </li>
@@ -586,6 +652,29 @@
color: var(--text); 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 { .continue {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { fileUrl } from '$lib/api/client'; import { fileUrl, ApiError } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress'; import { updateReadProgress } from '$lib/api/read_progress';
import { chapterLabel } from '$lib/api/chapters';
import { resyncChapter } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte'; import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -15,6 +17,7 @@
import ScrollText from '@lucide/svelte/icons/scroll-text'; import ScrollText from '@lucide/svelte/icons/scroll-text';
import Maximize2 from '@lucide/svelte/icons/maximize-2'; import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2'; import Minimize2 from '@lucide/svelte/icons/minimize-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props(); let { data } = $props();
const manga = $derived(data.manga); const manga = $derived(data.manga);
@@ -26,28 +29,25 @@
const gapPx = $derived(GAP_PX[preferences.readerPageGap]); const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
const pageTitle = $derived( const pageTitle = $derived(
chapter.title `Mangalord | ${manga.title} · ${chapterLabel(chapter)}`
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
: `${manga.title} — Ch. ${chapter.number}`
); );
// Prev/next chapter computed from the chapter list. listChapters // Prev/next chapter computed from the chapter list. listChapters
// returns chapters in number ASC order; we still resolve via find // returns chapters in display order (reversed source-site order, so
// rather than index because the current chapter's position may // oldest first — see backend repo::chapter::list_for_manga), and
// not be `chapter.number - 1` (sparse numbering / chapter 0.5 / // prev/next walks that order positionally. Resolving the current
// future skipped numbers). // index via `find` rather than `chapter.number - 1` matters because
const sortedChapters = $derived( // numbers aren't a reliable index: variants share numbers, non-
[...chapters].sort((a, b) => a.number - b.number) // numeric entries pin to 0, and uploads can sparse-fill.
);
const currentIdx = $derived( const currentIdx = $derived(
sortedChapters.findIndex((c) => c.id === chapter.id) chapters.findIndex((c) => c.id === chapter.id)
); );
const prevChapter = $derived( const prevChapter = $derived(
currentIdx > 0 ? sortedChapters[currentIdx - 1] : null currentIdx > 0 ? chapters[currentIdx - 1] : null
); );
const nextChapter = $derived( const nextChapter = $derived(
currentIdx >= 0 && currentIdx < sortedChapters.length - 1 currentIdx >= 0 && currentIdx < chapters.length - 1
? sortedChapters[currentIdx + 1] ? chapters[currentIdx + 1]
: null : null
); );
@@ -256,6 +256,36 @@
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown); 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 ---- // ---- Reading progress tracking ----
// //
// High-water mark seeded from the server: progress only ever moves // High-water mark seeded from the server: progress only ever moves
@@ -427,6 +457,27 @@
</a> </a>
<div class="controls" role="group" aria-label="reader options"> <div class="controls" role="group" aria-label="reader options">
<label class="chapter-field">
<span class="visually-hidden">Jump to chapter</span>
<select
class="chapter-select"
value={chapter.id}
onchange={(e) => {
const target = (e.currentTarget as HTMLSelectElement).value;
if (target && target !== chapter.id) {
void goto(`/manga/${manga.id}/chapter/${target}`);
}
}}
data-testid="reader-chapter-select"
>
{#each chapters as c (c.id)}
<option value={c.id}>
{chapterLabel(c)}
</option>
{/each}
</select>
</label>
<div class="mode-toggle" role="radiogroup" aria-label="layout"> <div class="mode-toggle" role="radiogroup" aria-label="layout">
<button <button
type="button" type="button"
@@ -481,6 +532,23 @@
{/if} {/if}
</span> </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 <button
type="button" type="button"
class="fullscreen-toggle" class="fullscreen-toggle"
@@ -494,6 +562,17 @@
</button> </button>
</nav> </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. Floating exit affordance — only rendered while focus mode is on.
Lives in the top-right corner with a low resting opacity so it Lives in the top-right corner with a low resting opacity so it
@@ -604,7 +683,7 @@
</span> </span>
</button> </button>
<span class="chapter-bar-current" aria-hidden="true"> <span class="chapter-bar-current" aria-hidden="true">
Ch. {chapter.number}{#if chapter.title}{chapter.title}{/if} {chapterLabel(chapter)}
</span> </span>
<button <button
type="button" type="button"
@@ -741,7 +820,8 @@
outline-offset: -2px; outline-offset: -2px;
} }
.gap-field select { .gap-field select,
.chapter-select {
height: 32px; height: 32px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
background: var(--surface); background: var(--surface);
@@ -751,6 +831,13 @@
font-size: var(--font-sm); font-size: var(--font-sm);
} }
/* Cap the chapter dropdown's resting width so long titles don't
push the rest of the nav off-screen; the native control's
expanded menu still shows full option text on focus. */
.chapter-select {
max-width: 16rem;
}
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -911,7 +998,8 @@
} }
/* ===== Focus-mode controls ===== */ /* ===== Focus-mode controls ===== */
.fullscreen-toggle { .fullscreen-toggle,
.reader-resync {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
@@ -925,12 +1013,52 @@
font-size: var(--font-xs); font-size: var(--font-xs);
} }
.fullscreen-toggle:hover { .fullscreen-toggle:hover,
.reader-resync:hover:not(:disabled) {
background: var(--surface-elevated); background: var(--surface-elevated);
color: var(--text); color: var(--text);
border-color: var(--primary); 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 /* Small floating exit affordance — corner-pinned, low resting
opacity so it doesn't sit on the chapter image too aggressively opacity so it doesn't sit on the chapter image too aggressively
but is still findable without hover. */ but is still findable without hover. */

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fileUrl } from '$lib/api/client'; import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress'; import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image'; import BookImage from '@lucide/svelte/icons/book-image';
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
@@ -186,7 +187,7 @@
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a> <a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
<span class="target"> <span class="target">
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}"> <a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if} {chapterLabel(u.chapter)}
</a> </a>
<span class="muted">({u.chapter.page_count} pages)</span> <span class="muted">({u.chapter.page_count} pages)</span>
</span> </span>

View File

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

View File

@@ -21,6 +21,12 @@ export default defineConfig(({ mode }) => {
environment: 'jsdom', environment: 'jsdom',
include: ['src/**/*.test.ts'], include: ['src/**/*.test.ts'],
globals: false 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'] : []
} }
}; };
}); });