Compare commits
5 Commits
feat/cover
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679abae736 | ||
|
|
b812c6d16c | ||
|
|
e93eec89e5 | ||
|
|
8818c890c5 | ||
|
|
c134bdbbde |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.49.1"
|
version = "0.52.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.49.1"
|
version = "0.52.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
18
backend/migrations/0021_chapter_source_index.sql
Normal file
18
backend/migrations/0021_chapter_source_index.sql
Normal 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;
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
176
backend/src/api/admin/resync.rs
Normal file
176
backend/src/api/admin/resync.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
277
backend/src/crawler/resync.rs
Normal file
277
backend/src/crawler/resync.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
350
backend/tests/api_admin_resync.rs
Normal file
350
backend/tests/api_admin_resync.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
167
frontend/e2e/reader-chapter-select.spec.ts
Normal file
167
frontend/e2e/reader-chapter-select.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.49.1",
|
"version": "0.52.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +185,31 @@
|
|||||||
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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)}`
|
||||||
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
|
|
||||||
: `Mangalord | ${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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user