Files
stripstream-librarian/apps/api/src/stats.rs
Froidefond Julien bc796f4ee5 feat: multi-user reading progress & backoffice impersonation
- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:47:58 +01:00

820 lines
28 KiB
Rust

use axum::{
extract::{Extension, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::{IntoParams, ToSchema};
use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct StatsOverview {
pub total_books: i64,
pub total_series: i64,
pub total_libraries: i64,
pub total_pages: i64,
pub total_size_bytes: i64,
pub total_authors: i64,
}
#[derive(Serialize, ToSchema)]
pub struct ReadingStatusStats {
pub unread: i64,
pub reading: i64,
pub read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct FormatCount {
pub format: String,
pub count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct LanguageCount {
pub language: Option<String>,
pub count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct LibraryStats {
pub library_name: String,
pub book_count: i64,
pub size_bytes: i64,
pub read_count: i64,
pub reading_count: i64,
pub unread_count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct TopSeries {
pub series: String,
pub book_count: i64,
pub read_count: i64,
pub total_pages: i64,
}
#[derive(Serialize, ToSchema)]
pub struct MonthlyAdditions {
pub month: String,
pub books_added: i64,
}
#[derive(Serialize, ToSchema)]
pub struct MetadataStats {
pub total_series: i64,
pub series_linked: i64,
pub series_unlinked: i64,
pub books_with_summary: i64,
pub books_with_isbn: i64,
pub by_provider: Vec<ProviderCount>,
}
#[derive(Serialize, ToSchema)]
pub struct ProviderCount {
pub provider: String,
pub count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct CurrentlyReadingItem {
pub book_id: String,
pub title: String,
pub series: Option<String>,
pub current_page: i32,
pub page_count: i32,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct RecentlyReadItem {
pub book_id: String,
pub title: String,
pub series: Option<String>,
pub last_read_at: String,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct MonthlyReading {
pub month: String,
pub books_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct UserMonthlyReading {
pub month: String,
pub username: String,
pub books_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct JobTimePoint {
pub label: String,
pub scan: i64,
pub rebuild: i64,
pub thumbnail: i64,
pub other: i64,
}
#[derive(Serialize, ToSchema)]
pub struct StatsResponse {
pub overview: StatsOverview,
pub reading_status: ReadingStatusStats,
pub currently_reading: Vec<CurrentlyReadingItem>,
pub recently_read: Vec<RecentlyReadItem>,
pub reading_over_time: Vec<MonthlyReading>,
pub by_format: Vec<FormatCount>,
pub by_language: Vec<LanguageCount>,
pub by_library: Vec<LibraryStats>,
pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>,
pub jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats,
pub users_reading_over_time: Vec<UserMonthlyReading>,
}
/// Get collection statistics for the dashboard
#[utoipa::path(
get,
path = "/stats",
tag = "stats",
params(StatsQuery),
responses(
(status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_stats(
State(state): State<AppState>,
Query(query): Query<StatsQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<StatsResponse>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query
let overview_row = sqlx::query(
r#"
SELECT
COUNT(*) AS total_books,
COUNT(DISTINCT NULLIF(series, '')) AS total_series,
COUNT(DISTINCT library_id) AS total_libraries,
COALESCE(SUM(page_count), 0)::BIGINT AS total_pages,
(SELECT COUNT(DISTINCT a) FROM (
SELECT DISTINCT UNNEST(authors) AS a FROM books WHERE authors != '{}'
UNION
SELECT DISTINCT author FROM books WHERE author IS NOT NULL AND author != ''
) sub) AS total_authors,
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread,
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
"#,
)
.bind(user_id)
.fetch_one(&state.pool)
.await?;
// Total size from book_files
let size_row = sqlx::query(
r#"
SELECT COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS total_size_bytes
FROM (
SELECT DISTINCT ON (book_id) size_bytes
FROM book_files
ORDER BY book_id, updated_at DESC
) bf
"#,
)
.fetch_one(&state.pool)
.await?;
let overview = StatsOverview {
total_books: overview_row.get("total_books"),
total_series: overview_row.get("total_series"),
total_libraries: overview_row.get("total_libraries"),
total_pages: overview_row.get("total_pages"),
total_size_bytes: size_row.get("total_size_bytes"),
total_authors: overview_row.get("total_authors"),
};
let reading_status = ReadingStatusStats {
unread: overview_row.get("unread"),
reading: overview_row.get("reading"),
read: overview_row.get("read"),
};
// By format
let format_rows = sqlx::query(
r#"
SELECT COALESCE(bf.format, b.kind) AS fmt, COUNT(*) AS count
FROM books b
LEFT JOIN LATERAL (
SELECT format FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
) bf ON TRUE
GROUP BY fmt
ORDER BY count DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let by_format: Vec<FormatCount> = format_rows
.iter()
.map(|r| FormatCount {
format: r.get::<Option<String>, _>("fmt").unwrap_or_else(|| "unknown".to_string()),
count: r.get("count"),
})
.collect();
// By language
let lang_rows = sqlx::query(
r#"
SELECT language, COUNT(*) AS count
FROM books
GROUP BY language
ORDER BY count DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let by_language: Vec<LanguageCount> = lang_rows
.iter()
.map(|r| LanguageCount {
language: r.get("language"),
count: r.get("count"),
})
.collect();
// By library
let lib_rows = sqlx::query(
r#"
SELECT
l.name AS library_name,
COUNT(b.id) AS book_count,
COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS size_bytes,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading_count,
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
FROM libraries l
LEFT JOIN books b ON b.library_id = l.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
LEFT JOIN LATERAL (
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
) bf ON TRUE
GROUP BY l.id, l.name
ORDER BY book_count DESC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let by_library: Vec<LibraryStats> = lib_rows
.iter()
.map(|r| LibraryStats {
library_name: r.get("library_name"),
book_count: r.get("book_count"),
size_bytes: r.get("size_bytes"),
read_count: r.get("read_count"),
reading_count: r.get("reading_count"),
unread_count: r.get("unread_count"),
})
.collect();
// Top series (by book count)
let series_rows = sqlx::query(
r#"
SELECT
b.series,
COUNT(*) AS book_count,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
WHERE b.series IS NOT NULL AND b.series != ''
GROUP BY b.series
ORDER BY book_count DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let top_series: Vec<TopSeries> = series_rows
.iter()
.map(|r| TopSeries {
series: r.get("series"),
book_count: r.get("book_count"),
read_count: r.get("read_count"),
total_pages: r.get("total_pages"),
})
.collect();
// Additions over time (with gap filling)
let additions_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT created_at::date AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY created_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter()
.map(|r| MonthlyAdditions {
month: r.get("month"),
books_added: r.get("books_added"),
})
.collect();
// Metadata stats
let meta_row = sqlx::query(
r#"
SELECT
(SELECT COUNT(DISTINCT NULLIF(series, '')) FROM books) AS total_series,
(SELECT COUNT(DISTINCT series_name) FROM external_metadata_links WHERE status = 'approved') AS series_linked,
(SELECT COUNT(*) FROM books WHERE summary IS NOT NULL AND summary != '') AS books_with_summary,
(SELECT COUNT(*) FROM books WHERE isbn IS NOT NULL AND isbn != '') AS books_with_isbn
"#,
)
.fetch_one(&state.pool)
.await?;
let meta_total_series: i64 = meta_row.get("total_series");
let meta_series_linked: i64 = meta_row.get("series_linked");
let provider_rows = sqlx::query(
r#"
SELECT provider, COUNT(DISTINCT series_name) AS count
FROM external_metadata_links
WHERE status = 'approved'
GROUP BY provider
ORDER BY count DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let by_provider: Vec<ProviderCount> = provider_rows
.iter()
.map(|r| ProviderCount {
provider: r.get("provider"),
count: r.get("count"),
})
.collect();
let metadata = MetadataStats {
total_series: meta_total_series,
series_linked: meta_series_linked,
series_unlinked: meta_total_series - meta_series_linked,
books_with_summary: meta_row.get("books_with_summary"),
books_with_isbn: meta_row.get("books_with_isbn"),
by_provider,
};
// Currently reading books
let reading_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.updated_at DESC
LIMIT 20
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let currently_reading: Vec<CurrentlyReadingItem> = reading_rows
.iter()
.map(|r| {
let id: uuid::Uuid = r.get("book_id");
CurrentlyReadingItem {
book_id: id.to_string(),
title: r.get("title"),
series: r.get("series"),
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
username: r.get("username"),
}
})
.collect();
// Recently read books
let recent_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series,
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.last_read_at DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let recently_read: Vec<RecentlyReadItem> = recent_rows
.iter()
.map(|r| {
let id: uuid::Uuid = r.get("book_id");
RecentlyReadItem {
book_id: id.to_string(),
title: r.get("title"),
series: r.get("series"),
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
username: r.get("username"),
}
})
.collect();
// Reading activity over time (with gap filling)
let reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter()
.map(|r| MonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
books_read: r.get("books_read"),
})
.collect();
// Per-user reading over time (admin view — always all users, no user_id filter)
let users_reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY brp.last_read_at::date, brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
.iter()
.map(|r| UserMonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
username: r.get("username"),
books_read: r.get("books_read"),
})
.collect();
// Jobs over time (with gap filling, grouped by type category)
let jobs_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT
finished_at::date AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY finished_at::date, cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('week', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('month', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
.iter()
.map(|r| JobTimePoint {
label: r.get("label"),
scan: r.get("scan"),
rebuild: r.get("rebuild"),
thumbnail: r.get("thumbnail"),
other: r.get("other"),
})
.collect();
Ok(Json(StatsResponse {
overview,
reading_status,
currently_reading,
recently_read,
reading_over_time,
by_format,
by_language,
by_library,
top_series,
additions_over_time,
jobs_over_time,
metadata,
users_reading_over_time,
}))
}