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:
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