feat(api): admin manga/chapter overview with derived sync state (0.39.0)
Adds GET /api/v1/admin/mangas and /admin/mangas/:id/chapters guarded by RequireAdmin. Sync state is computed at query time from the existing crawler signals (manga_sources / chapter_sources / crawler_jobs) — no new state column is persisted, so the crawler stays the single writer of these signals. Per-manga priority: InProgress (in-flight sync_manga or sync_chapter_list job) > Dropped (all source rows soft-dropped) > Synced (default; covers user-uploaded mangas with zero source rows). Per-chapter priority: Downloading (in-flight sync_chapter_content) > Dropped (all source rows soft-dropped) > Failed (most-recent terminal job is dead) > NotDownloaded (page_count = 0) > Synced. The Failed check sits ABOVE NotDownloaded so the more informative "we tried and it died" state wins over "we never got around to it" — see the priority comment in repo/admin_view.rs. Migration 0020 adds a partial index on crawler_jobs((payload->>'source_manga_key')) for the one job kind (sync_manga) whose payload doesn't carry manga_id directly — without it the in-flight detection for a manga falls back to a seqscan over the job table.
This commit is contained in:
82
backend/src/api/admin/mangas.rs
Normal file
82
backend/src/api/admin/mangas.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Admin manga/chapter overview with derived sync state.
|
||||
//!
|
||||
//! Sync state comes from `repo::admin_view`, which joins the manga /
|
||||
//! chapter tables with the crawler signals at query time — there is no
|
||||
//! persisted sync_state column. See [`repo::admin_view`] for the
|
||||
//! derivation priority order.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::domain::MangaSyncState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::repo::admin_view::{AdminChapterRow, AdminMangaRow};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/mangas", get(list_mangas))
|
||||
.route("/admin/mangas/:id/chapters", get(list_chapters))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListMangasParams {
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
/// `in_progress` | `dropped` | `synced`. Unrecognised values are a 400.
|
||||
#[serde(default)]
|
||||
pub sync_state: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
async fn list_mangas(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Query(params): Query<ListMangasParams>,
|
||||
) -> AppResult<Json<PagedResponse<AdminMangaRow>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
|
||||
let sync_state = match params.sync_state.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some("in_progress") => Some(MangaSyncState::InProgress),
|
||||
Some("dropped") => Some(MangaSyncState::Dropped),
|
||||
Some("synced") => Some(MangaSyncState::Synced),
|
||||
Some(other) => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"sync_state must be one of in_progress|dropped|synced (got {other:?})"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let q = repo::admin_view::ListAdminMangasQuery {
|
||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||
sync_state,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (items, total) = repo::admin_view::list_mangas_with_sync_state(&state.db, &q).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_chapters(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<AdminChapterRow>>> {
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&state.db, manga_id).await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! bot/API tokens cannot reach admin routes (see
|
||||
//! `crate::auth::extractor::RequireAdmin`).
|
||||
|
||||
pub mod mangas;
|
||||
pub mod users;
|
||||
|
||||
use axum::Router;
|
||||
@@ -11,5 +12,5 @@ use axum::Router;
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().merge(users::routes())
|
||||
Router::new().merge(users::routes()).merge(mangas::routes())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod page;
|
||||
pub mod patch;
|
||||
pub mod read_progress;
|
||||
pub mod session;
|
||||
pub mod sync_state;
|
||||
pub mod tag;
|
||||
pub mod upload_entry;
|
||||
pub mod user;
|
||||
@@ -27,6 +28,7 @@ pub use page::Page;
|
||||
pub use patch::Patch;
|
||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||
pub use session::Session;
|
||||
pub use sync_state::{ChapterSyncState, MangaSyncState};
|
||||
pub use tag::{Tag, TagRef};
|
||||
pub use upload_entry::UploadEntry;
|
||||
pub use user::User;
|
||||
|
||||
46
backend/src/domain/sync_state.rs
Normal file
46
backend/src/domain/sync_state.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Sync-state enums derived per-manga / per-chapter from `manga_sources`,
|
||||
//! `chapter_sources`, and `crawler_jobs` at query time. No state column
|
||||
//! is persisted on `mangas` / `chapters` — see `repo::admin_view` for the
|
||||
//! derivation rules and priority order.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MangaSyncState {
|
||||
/// A `sync_manga` or `sync_chapter_list` job is currently
|
||||
/// pending or running for this manga.
|
||||
InProgress,
|
||||
/// At least one `manga_sources` row exists for this manga and ALL of
|
||||
/// them have `dropped_at IS NOT NULL` — every source we know about
|
||||
/// has stopped surfacing it.
|
||||
Dropped,
|
||||
/// Default healthy state: at least one live source row OR the manga
|
||||
/// was user-uploaded (no `manga_sources` rows at all).
|
||||
Synced,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChapterSyncState {
|
||||
/// A `sync_chapter_content` job is currently pending or running for
|
||||
/// this chapter (the 0014 dedup index guarantees at most one).
|
||||
Downloading,
|
||||
/// At least one `chapter_sources` row exists AND all of them are
|
||||
/// `dropped_at IS NOT NULL`.
|
||||
Dropped,
|
||||
/// Most recent `sync_chapter_content` job for this chapter is `dead`
|
||||
/// (terminal failure). Checked BEFORE `NotDownloaded` so the more
|
||||
/// informative "we tried and it died" state wins over "we never
|
||||
/// got around to it".
|
||||
Failed,
|
||||
/// `page_count = 0` and no in-flight or failed job — the chapter
|
||||
/// row exists but content has never been downloaded.
|
||||
NotDownloaded,
|
||||
/// `page_count > 0` — content has been downloaded at some point.
|
||||
/// Reaped `done` jobs in `crawler_jobs` mean we can't read this from
|
||||
/// the job table, so `page_count` is the durable truth.
|
||||
Synced,
|
||||
}
|
||||
204
backend/src/repo/admin_view.rs
Normal file
204
backend/src/repo/admin_view.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! Admin-facing read queries that join manga/chapter with the crawler
|
||||
//! signals (`manga_sources`, `chapter_sources`, `crawler_jobs`) to
|
||||
//! derive a sync state per row at query time.
|
||||
//!
|
||||
//! Priority order for `MangaSyncState`:
|
||||
//! 1. `InProgress` — any pending/running `sync_manga` or
|
||||
//! `sync_chapter_list` job matches this manga.
|
||||
//! 2. `Dropped` — manga has source rows AND every one of them is
|
||||
//! `dropped_at IS NOT NULL`.
|
||||
//! 3. `Synced` — default (includes user-uploaded mangas with no
|
||||
//! `manga_sources` rows at all).
|
||||
//!
|
||||
//! Priority order for `ChapterSyncState`:
|
||||
//! 1. `Downloading` — pending/running `sync_chapter_content` for this id
|
||||
//! 2. `Dropped` — chapter has source rows AND all are dropped
|
||||
//! 3. `Failed` — most recent terminal `sync_chapter_content` is `dead`
|
||||
//! 4. `NotDownloaded` — `page_count = 0`
|
||||
//! 5. `Synced` — `page_count > 0`
|
||||
//!
|
||||
//! Reminder: `done` jobs are reaped after `CRAWLER_JOB_RETENTION_DAYS`,
|
||||
//! so `chapters.page_count > 0` is the durable "this is synced" signal,
|
||||
//! not the job table.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::{ChapterSyncState, MangaSyncState};
|
||||
use crate::error::AppResult;
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AdminMangaRow {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub cover_image_path: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub sync_state: MangaSyncState,
|
||||
pub chapter_count: i64,
|
||||
pub latest_seen_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ListAdminMangasQuery {
|
||||
pub search: Option<String>,
|
||||
pub sync_state: Option<MangaSyncState>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
const MANGA_SYNC_STATE_CASE: &str = r#"
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM crawler_jobs cj
|
||||
WHERE cj.state IN ('pending','running')
|
||||
AND (
|
||||
(cj.payload->>'kind' = 'sync_chapter_list'
|
||||
AND (cj.payload->>'manga_id')::uuid = m.id)
|
||||
OR (cj.payload->>'kind' = 'sync_manga'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM manga_sources ms
|
||||
WHERE ms.manga_id = m.id
|
||||
AND ms.source_id = cj.payload->>'source_id'
|
||||
AND ms.source_manga_key = cj.payload->>'source_manga_key'
|
||||
))
|
||||
)
|
||||
) THEN 'in_progress'
|
||||
WHEN EXISTS (SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manga_sources ms
|
||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||
)
|
||||
THEN 'dropped'
|
||||
ELSE 'synced'
|
||||
END
|
||||
"#;
|
||||
|
||||
/// Paginated admin manga list with derived sync state and total count.
|
||||
/// Filters by `search` (substring on title, case-insensitive) and
|
||||
/// `sync_state` (post-derivation). The CTE keeps the case expression
|
||||
/// in one place — the same projection feeds both the page rows and the
|
||||
/// totals count under the same filter.
|
||||
pub async fn list_mangas_with_sync_state(
|
||||
pool: &PgPool,
|
||||
q: &ListAdminMangasQuery,
|
||||
) -> AppResult<(Vec<AdminMangaRow>, i64)> {
|
||||
let search_pat = q
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.trim()))
|
||||
.filter(|p| p.len() > 2);
|
||||
// sqlx::Type → text: bind the snake_case representation manually so
|
||||
// the SQL can compare it as text without an explicit cast.
|
||||
let sync_filter = q.sync_state.map(|s| match s {
|
||||
MangaSyncState::InProgress => "in_progress",
|
||||
MangaSyncState::Dropped => "dropped",
|
||||
MangaSyncState::Synced => "synced",
|
||||
});
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH classified AS (
|
||||
SELECT
|
||||
m.id, m.title, m.status, m.cover_image_path,
|
||||
m.created_at, m.updated_at,
|
||||
{case} AS sync_state,
|
||||
(SELECT COUNT(*) FROM chapters c WHERE c.manga_id = m.id) AS chapter_count,
|
||||
(SELECT MAX(last_seen_at) FROM manga_sources ms
|
||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
|
||||
FROM mangas m
|
||||
WHERE ($1::text IS NULL OR m.title ILIKE $1)
|
||||
)
|
||||
SELECT * FROM classified
|
||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
case = MANGA_SYNC_STATE_CASE
|
||||
);
|
||||
let items: Vec<AdminMangaRow> = sqlx::query_as(&sql)
|
||||
.bind(&search_pat)
|
||||
.bind(sync_filter)
|
||||
.bind(q.limit)
|
||||
.bind(q.offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let total_sql = format!(
|
||||
r#"
|
||||
WITH classified AS (
|
||||
SELECT {case} AS sync_state
|
||||
FROM mangas m
|
||||
WHERE ($1::text IS NULL OR m.title ILIKE $1)
|
||||
)
|
||||
SELECT COUNT(*) FROM classified
|
||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||
"#,
|
||||
case = MANGA_SYNC_STATE_CASE
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar(&total_sql)
|
||||
.bind(&search_pat)
|
||||
.bind(sync_filter)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AdminChapterRow {
|
||||
pub id: Uuid,
|
||||
pub manga_id: Uuid,
|
||||
pub number: i32,
|
||||
pub title: Option<String>,
|
||||
pub page_count: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub sync_state: ChapterSyncState,
|
||||
pub latest_seen_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn list_chapters_with_sync_state(
|
||||
pool: &PgPool,
|
||||
manga_id: Uuid,
|
||||
) -> AppResult<Vec<AdminChapterRow>> {
|
||||
let rows: Vec<AdminChapterRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
c.id, c.manga_id, c.number, c.title, c.page_count, c.created_at,
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM crawler_jobs cj
|
||||
WHERE cj.state IN ('pending','running')
|
||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||
AND (cj.payload->>'chapter_id')::uuid = c.id
|
||||
) THEN 'downloading'
|
||||
WHEN EXISTS (SELECT 1 FROM chapter_sources cs WHERE cs.chapter_id = c.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chapter_sources cs
|
||||
WHERE cs.chapter_id = c.id AND cs.dropped_at IS NULL
|
||||
)
|
||||
THEN 'dropped'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM crawler_jobs cj
|
||||
WHERE cj.state = 'dead'
|
||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||
AND (cj.payload->>'chapter_id')::uuid = c.id
|
||||
) THEN 'failed'
|
||||
WHEN c.page_count = 0 THEN 'not_downloaded'
|
||||
ELSE 'synced'
|
||||
END AS sync_state,
|
||||
(SELECT MAX(last_seen_at) FROM chapter_sources cs
|
||||
WHERE cs.chapter_id = c.id AND cs.dropped_at IS NULL) AS latest_seen_at
|
||||
FROM chapters c
|
||||
WHERE c.manga_id = $1
|
||||
ORDER BY c.number ASC
|
||||
"#,
|
||||
)
|
||||
.bind(manga_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod admin_audit;
|
||||
pub mod admin_view;
|
||||
pub mod api_token;
|
||||
pub mod author;
|
||||
pub mod bookmark;
|
||||
|
||||
Reference in New Issue
Block a user