Compare commits

...

12 Commits

Author SHA1 Message Date
0c42a9ed04 fix: add API job poller to process scheduler-created metadata jobs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m12s
The scheduler (indexer) created metadata_refresh/metadata_batch jobs in DB,
but the indexer excluded them (API_ONLY_JOB_TYPES) and the API only processed
jobs created via its REST endpoints. Scheduler-created jobs stayed pending forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:05:42 +01:00
95a6e54d06 chore: bump version to 1.27.1 2026-03-22 21:05:23 +01:00
e26219989f feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
- Add multi-line chart showing job runs over time by type (scan,
  rebuild, thumbnails, other) with the same day/week/month toggle
- Limit currently reading and recently read lists to 3 visible items
  with a scrollbar for overflow
- Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:43:45 +01:00
5d33a35407 chore: bump version to 1.27.0 2026-03-22 10:43:25 +01:00
d53572dc33 chore: bump version to 1.26.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-22 10:27:59 +01:00
cf1953d11f feat: add day/week/month period toggle for dashboard line charts
Add a period selector (day, week, month) to the reading activity and
books added charts. The API now accepts a ?period= query param and
returns gap-filled data using generate_series so all time slots appear
even with zero values. Labels are locale-aware (short month, weekday).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:27:24 +01:00
6f663eaee7 docs: add MIT license
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:08:15 +01:00
ee65c6263a perf: add ETag and server-side caching for thumbnail proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 49s
Add ETag header to API thumbnail responses for 304 Not Modified support.
Forward If-None-Match/ETag through the Next.js proxy route handler and
add next.revalidate for 24h server-side fetch caching to reduce
SSR-to-API round trips on the libraries page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:52:47 +01:00
691b6b22ab chore: bump version to 1.25.0 2026-03-22 06:52:02 +01:00
11c80a16a3 docs: add Telegram notifications and updated dashboard to README and FEATURES
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:40:34 +01:00
c366b44c54 chore: bump version to 1.24.1 2026-03-22 06:39:23 +01:00
92f80542e6 perf: skip Next.js image re-optimization and stream proxy responses
Thumbnails are already optimized (WebP) by the API, so disable Next.js
image optimization to avoid redundant CPU work. Switch route handlers
from buffering (arrayBuffer) to streaming (response.body) to reduce
memory usage and latency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:38:46 +01:00
21 changed files with 752 additions and 108 deletions

10
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api" name = "api"
version = "1.24.0" version = "1.27.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -1233,7 +1233,7 @@ dependencies = [
[[package]] [[package]]
name = "indexer" name = "indexer"
version = "1.24.0" version = "1.27.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1667,7 +1667,7 @@ dependencies = [
[[package]] [[package]]
name = "notifications" name = "notifications"
version = "1.24.0" version = "1.27.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"reqwest", "reqwest",
@@ -1786,7 +1786,7 @@ dependencies = [
[[package]] [[package]]
name = "parsers" name = "parsers"
version = "1.24.0" version = "1.27.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flate2", "flate2",
@@ -2923,7 +2923,7 @@ dependencies = [
[[package]] [[package]]
name = "stripstream-core" name = "stripstream-core"
version = "1.24.0" version = "1.27.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",

View File

@@ -10,7 +10,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "1.24.0" version = "1.27.1"
license = "MIT" license = "MIT"
[workspace.dependencies] [workspace.dependencies]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Julien Froidefond
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -110,6 +110,12 @@ The backoffice will be available at http://localhost:7082
- Batch auto-matching and scheduled metadata refresh - Batch auto-matching and scheduled metadata refresh
- Field locking to protect manual edits from sync - Field locking to protect manual edits from sync
### Notifications
- **Telegram**: real-time notifications via Telegram Bot API
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
- Book thumbnail images included in notifications where applicable
- Test connection from settings
### External Integrations ### External Integrations
- **Komga**: import reading progress - **Komga**: import reading progress
- **Prowlarr**: search for missing volumes - **Prowlarr**: search for missing volumes
@@ -130,9 +136,11 @@ The backoffice will be available at http://localhost:7082
- Rate limiting, token expiration and revocation - Rate limiting, token expiration and revocation
### Web UI (Backoffice) ### Web UI (Backoffice)
- Dashboard with statistics, charts, and reading progress - Dashboard with statistics, interactive charts (recharts), and reading progress
- Currently reading & recently read sections
- Library, book, series, author management - Library, book, series, author management
- Live job monitoring, metadata search modals, settings panel - Live job monitoring, metadata search modals, settings panel
- Notification settings with per-event toggle configuration
## Environment Variables ## Environment Variables
@@ -279,4 +287,4 @@ volumes:
## License ## License
[Your License Here] This project is licensed under the [MIT License](LICENSE).

View File

@@ -631,12 +631,17 @@ pub async fn get_thumbnail(
crate::pages::render_book_page_1(&state, book_id, 300, 80).await? crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
}; };
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert( headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"), HeaderValue::from_static("public, max-age=31536000, immutable"),
); );
if let Ok(v) = HeaderValue::from_str(&etag_value) {
headers.insert(header::ETAG, v);
}
Ok((StatusCode::OK, headers, Body::from(data))) Ok((StatusCode::OK, headers, Body::from(data)))
} }

134
apps/api/src/job_poller.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::time::Duration;
use sqlx::{PgPool, Row};
use tracing::{error, info, trace};
use uuid::Uuid;
use crate::{metadata_batch, metadata_refresh};
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
/// This mirrors the indexer's worker loop but for job types handled by the API.
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
let wait = Duration::from_secs(interval_seconds.max(1));
loop {
match claim_next_api_job(&pool).await {
Ok(Some((job_id, job_type, library_id))) => {
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
let pool_clone = pool.clone();
let library_name: Option<String> =
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
let result = match job_type.as_str() {
"metadata_refresh" => {
metadata_refresh::process_metadata_refresh(
&pool_clone,
job_id,
library_id,
)
.await
}
"metadata_batch" => {
metadata_batch::process_metadata_batch(
&pool_clone,
job_id,
library_id,
)
.await
}
_ => Err(format!("Unknown API job type: {job_type}")),
};
if let Err(e) = result {
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool_clone)
.await;
match job_type.as_str() {
"metadata_refresh" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
"metadata_batch" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataBatchFailed {
library_name,
error: e.to_string(),
},
);
}
_ => {}
}
}
});
}
Ok(None) => {
trace!("[JOB_POLLER] No pending API jobs, waiting...");
tokio::time::sleep(wait).await;
}
Err(err) => {
error!("[JOB_POLLER] Error claiming job: {err}");
tokio::time::sleep(wait).await;
}
}
}
}
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, type, library_id
FROM index_jobs
WHERE status = 'pending'
AND type = ANY($1)
AND library_id IS NOT NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.bind(API_JOB_TYPES)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.get("id");
let job_type: String = row.get("type");
let library_id: Uuid = row.get("library_id");
sqlx::query(
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((id, job_type, library_id)))
}

View File

@@ -4,6 +4,7 @@ mod books;
mod error; mod error;
mod handlers; mod handlers;
mod index_jobs; mod index_jobs;
mod job_poller;
mod komga; mod komga;
mod libraries; mod libraries;
mod metadata; mod metadata;
@@ -159,6 +160,9 @@ async fn main() -> anyhow::Result<()> {
auth::require_read, auth::require_read,
)); ));
// Clone pool before state is moved into the router
let poller_pool = state.pool.clone();
let app = Router::new() let app = Router::new()
.route("/health", get(handlers::health)) .route("/health", get(handlers::health))
.route("/ready", get(handlers::ready)) .route("/ready", get(handlers::ready))
@@ -170,6 +174,11 @@ async fn main() -> anyhow::Result<()> {
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter)) .layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
.with_state(state); .with_state(state);
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
tokio::spawn(async move {
job_poller::run_job_poller(poller_pool, 5).await;
});
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening"); info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View File

@@ -115,14 +115,14 @@ pub async fn start_batch(
let job_id = Uuid::new_v4(); let job_id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')", "INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
) )
.bind(job_id) .bind(job_id)
.bind(library_id) .bind(library_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Spawn the background processing task // Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone(); let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1") let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id) .bind(library_id)
@@ -313,7 +313,7 @@ pub async fn get_batch_results(
// Background processing // Background processing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn process_metadata_batch( pub(crate) async fn process_metadata_batch(
pool: &PgPool, pool: &PgPool,
job_id: Uuid, job_id: Uuid,
library_id: Uuid, library_id: Uuid,

View File

@@ -124,14 +124,14 @@ pub async fn start_refresh(
let job_id = Uuid::new_v4(); let job_id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')", "INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
) )
.bind(job_id) .bind(job_id)
.bind(library_id) .bind(library_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Spawn the background processing task // Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone(); let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1") let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id) .bind(library_id)
@@ -222,7 +222,7 @@ pub async fn get_refresh_report(
// Background processing // Background processing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn process_metadata_refresh( pub(crate) async fn process_metadata_refresh(
pool: &PgPool, pool: &PgPool,
job_id: Uuid, job_id: Uuid,
library_id: Uuid, library_id: Uuid,

View File

@@ -1,10 +1,19 @@
use axum::{extract::State, Json}; use axum::{
use serde::Serialize; extract::{Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct StatsOverview { pub struct StatsOverview {
pub total_books: i64, pub total_books: i64,
@@ -97,6 +106,15 @@ pub struct MonthlyReading {
pub books_read: i64, 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)] #[derive(Serialize, ToSchema)]
pub struct StatsResponse { pub struct StatsResponse {
pub overview: StatsOverview, pub overview: StatsOverview,
@@ -109,6 +127,7 @@ pub struct StatsResponse {
pub by_library: Vec<LibraryStats>, pub by_library: Vec<LibraryStats>,
pub top_series: Vec<TopSeries>, pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>, pub additions_over_time: Vec<MonthlyAdditions>,
pub jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats, pub metadata: MetadataStats,
} }
@@ -117,6 +136,7 @@ pub struct StatsResponse {
get, get,
path = "/stats", path = "/stats",
tag = "stats", tag = "stats",
params(StatsQuery),
responses( responses(
(status = 200, body = StatsResponse), (status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -125,7 +145,9 @@ pub struct StatsResponse {
)] )]
pub async fn get_stats( pub async fn get_stats(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<StatsQuery>,
) -> Result<Json<StatsResponse>, ApiError> { ) -> Result<Json<StatsResponse>, ApiError> {
let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query // Overview + reading status in one query
let overview_row = sqlx::query( let overview_row = sqlx::query(
r#" r#"
@@ -285,20 +307,74 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Additions over time (last 12 months) // Additions over time (with gap filling)
let additions_rows = sqlx::query( let additions_rows = match period {
r#" "day" => {
SELECT sqlx::query(
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month, r#"
COUNT(*) AS books_added SELECT
FROM books TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' COALESCE(cnt.books_added, 0) AS books_added
GROUP BY DATE_TRUNC('month', created_at) FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
ORDER BY month ASC LEFT JOIN (
"#, SELECT created_at::date AS dt, COUNT(*) AS books_added
) FROM books
.fetch_all(&state.pool) WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
.await?; 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 let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter() .iter()
@@ -409,21 +485,77 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Reading activity over time (last 12 months) // Reading activity over time (with gap filling)
let reading_time_rows = sqlx::query( let reading_time_rows = match period {
r#" "day" => {
SELECT sqlx::query(
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month, r#"
COUNT(*) AS books_read SELECT
FROM book_reading_progress brp TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
WHERE brp.status = 'read' COALESCE(cnt.books_read, 0) AS books_read
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
GROUP BY DATE_TRUNC('month', brp.last_read_at) LEFT JOIN (
ORDER BY month ASC SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
"#, FROM book_reading_progress brp
) WHERE brp.status = 'read'
.fetch_all(&state.pool) AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
.await?; GROUP BY brp.last_read_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_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')
GROUP BY DATE_TRUNC('week', brp.last_read_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_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'
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter() .iter()
@@ -433,6 +565,125 @@ pub async fn get_stats(
}) })
.collect(); .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 { Ok(Json(StatsResponse {
overview, overview,
reading_status, reading_status,
@@ -444,6 +695,7 @@ pub async fn get_stats(
by_library, by_library,
top_series, top_series,
additions_over_time, additions_over_time,
jobs_over_time,
metadata, metadata,
})) }))
} }

View File

@@ -21,19 +21,16 @@ export async function GET(
const response = await fetch(apiUrl.toString(), { const response = await fetch(apiUrl.toString(), {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!response.ok) { if (!response.ok) {
return new NextResponse(`Failed to fetch image: ${response.status}`, { return new NextResponse(`Failed to fetch image: ${response.status}`, {
status: response.status status: response.status
}); });
} }
// Récupérer le content-type et les données
const contentType = response.headers.get("content-type") || "image/webp"; const contentType = response.headers.get("content-type") || "image/webp";
const imageBuffer = await response.arrayBuffer();
return new NextResponse(response.body, {
// Retourner l'image avec le bon content-type
return new NextResponse(imageBuffer, {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "public, max-age=300", "Cache-Control": "public, max-age=300",

View File

@@ -6,28 +6,46 @@ export async function GET(
{ params }: { params: Promise<{ bookId: string }> } { params }: { params: Promise<{ bookId: string }> }
) { ) {
const { bookId } = await params; const { bookId } = await params;
try { try {
const { baseUrl, token } = config(); const { baseUrl, token } = config();
const ifNoneMatch = request.headers.get("if-none-match");
const fetchHeaders: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (ifNoneMatch) {
fetchHeaders["If-None-Match"] = ifNoneMatch;
}
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, { const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
headers: { Authorization: `Bearer ${token}` }, headers: fetchHeaders,
next: { revalidate: 86400 },
}); });
// Forward 304 Not Modified as-is
if (response.status === 304) {
return new NextResponse(null, { status: 304 });
}
if (!response.ok) { if (!response.ok) {
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, { return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
status: response.status status: response.status
}); });
} }
const contentType = response.headers.get("content-type") || "image/webp"; const contentType = response.headers.get("content-type") || "image/webp";
const imageBuffer = await response.arrayBuffer(); const etag = response.headers.get("etag");
return new NextResponse(imageBuffer, { const headers: Record<string, string> = {
headers: { "Content-Type": contentType,
"Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": "public, max-age=31536000, immutable", };
}, if (etag) {
}); headers["ETag"] = etag;
}
return new NextResponse(response.body, { headers });
} catch (error) { } catch (error) {
console.error("Error fetching thumbnail:", error); console.error("Error fetching thumbnail:", error);
return new NextResponse("Failed to fetch thumbnail", { status: 500 }); return new NextResponse("Failed to fetch thumbnail", { status: 500 });

View File

@@ -3,7 +3,7 @@
import { import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid, BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area, AreaChart, Area, Line, LineChart,
Legend, Legend,
} from "recharts"; } from "recharts";
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
</ResponsiveContainer> </ResponsiveContainer>
); );
} }
// ---------------------------------------------------------------------------
// Multi-line chart (jobs over time)
// ---------------------------------------------------------------------------
export function RcMultiLineChart({
data,
lines,
noDataLabel,
}: {
data: Record<string, unknown>[];
lines: { key: string; label: string; color: string }[];
noDataLabel?: string;
}) {
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
if (data.length === 0 || !hasData)
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.label}
stroke={l.color}
strokeWidth={2}
dot={{ r: 3, fill: l.color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Period = "day" | "week" | "month";
export function PeriodToggle({
labels,
}: {
labels: { day: string; week: string; month: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("period");
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
function setPeriod(period: Period) {
const params = new URLSearchParams(searchParams.toString());
if (period === "month") {
params.delete("period");
} else {
params.set("period", period);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Period[] = ["day", "week", "month"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === p
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[p]}
</button>
))}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api"; import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui"; import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts"; import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
import { PeriodToggle } from "./components/PeriodToggle";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getServerTranslations } from "../lib/i18n/server"; import { getServerTranslations } from "../lib/i18n/server";
@@ -21,6 +22,24 @@ function formatNumber(n: number, locale: string): string {
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US"); return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
} }
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
const loc = locale === "fr" ? "fr-FR" : "en-US";
if (period === "month") {
// raw = "YYYY-MM"
const [y, m] = raw.split("-");
const d = new Date(Number(y), Number(m) - 1, 1);
return d.toLocaleDateString(loc, { month: "short" });
}
if (period === "week") {
// raw = "YYYY-MM-DD" (Monday of the week)
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
}
// day: raw = "YYYY-MM-DD"
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
}
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed) // Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) { function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0; const pct = max > 0 ? (value / max) * 100 : 0;
@@ -40,12 +59,19 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
); );
} }
export default async function DashboardPage() { export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParamsAwaited = await searchParams;
const rawPeriod = searchParamsAwaited.period;
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
const { t, locale } = await getServerTranslations(); const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null; let stats: StatsResponse | null = null;
try { try {
stats = await fetchStats(); stats = await fetchStats(period);
} catch (e) { } catch (e) {
console.error("Failed to fetch stats:", e); console.error("Failed to fetch stats:", e);
} }
@@ -62,7 +88,7 @@ export default async function DashboardPage() {
); );
} }
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats; const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"]; const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [ const formatColors = [
@@ -110,7 +136,7 @@ export default async function DashboardPage() {
{currently_reading.length === 0 ? ( {currently_reading.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p> <p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{currently_reading.slice(0, 8).map((book) => { {currently_reading.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0; const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return ( return (
@@ -150,7 +176,7 @@ export default async function DashboardPage() {
{recently_read.length === 0 ? ( {recently_read.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p> <p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{recently_read.map((book) => ( {recently_read.map((book) => (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group"> <Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image <Image
@@ -175,20 +201,19 @@ export default async function DashboardPage() {
)} )}
{/* Reading activity line chart */} {/* Reading activity line chart */}
{reading_over_time.length > 0 && ( <Card hover={false}>
<Card hover={false}> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardHeader> <CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle> <PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))} data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)" color="hsl(142 60% 45%)"
/> />
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Charts row */} {/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -351,20 +376,47 @@ export default async function DashboardPage() {
</Card> </Card>
</div> </div>
{/* Monthly additions line chart full width */} {/* Additions line chart full width */}
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle> <CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))} data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
color="hsl(198 78% 37%)" color="hsl(198 78% 37%)"
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Jobs over time multi-line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcMultiLineChart
noDataLabel={noDataLabel}
data={jobs_over_time.map((j) => ({
label: formatChartLabel(j.label, period, locale),
scan: j.scan,
rebuild: j.rebuild,
thumbnail: j.thumbnail,
other: j.other,
}))}
lines={[
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
]}
/>
</CardContent>
</Card>
{/* Quick links */} {/* Quick links */}
<QuickLinks t={t} /> <QuickLinks t={t} />
</div> </div>

View File

@@ -570,6 +570,14 @@ export type MonthlyReading = {
books_read: number; books_read: number;
}; };
export type JobTimePoint = {
label: string;
scan: number;
rebuild: number;
thumbnail: number;
other: number;
};
export type StatsResponse = { export type StatsResponse = {
overview: StatsOverview; overview: StatsOverview;
reading_status: ReadingStatusStats; reading_status: ReadingStatusStats;
@@ -581,11 +589,13 @@ export type StatsResponse = {
by_library: LibraryStatsItem[]; by_library: LibraryStatsItem[];
top_series: TopSeriesItem[]; top_series: TopSeriesItem[];
additions_over_time: MonthlyAdditions[]; additions_over_time: MonthlyAdditions[];
jobs_over_time: JobTimePoint[];
metadata: MetadataStats; metadata: MetadataStats;
}; };
export async function fetchStats() { export async function fetchStats(period?: "day" | "week" | "month") {
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } }); const params = period && period !== "month" ? `?period=${period}` : "";
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -70,7 +70,15 @@ const en: Record<TranslationKey, string> = {
"dashboard.readingStatus": "Reading status", "dashboard.readingStatus": "Reading status",
"dashboard.byFormat": "By format", "dashboard.byFormat": "By format",
"dashboard.byLibrary": "By library", "dashboard.byLibrary": "By library",
"dashboard.booksAdded": "Books added (last 12 months)", "dashboard.booksAdded": "Books added",
"dashboard.jobsOverTime": "Job runs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Other",
"dashboard.periodDay": "Day",
"dashboard.periodWeek": "Week",
"dashboard.periodMonth": "Month",
"dashboard.popularSeries": "Popular series", "dashboard.popularSeries": "Popular series",
"dashboard.noSeries": "No series yet", "dashboard.noSeries": "No series yet",
"dashboard.unknown": "Unknown", "dashboard.unknown": "Unknown",
@@ -84,7 +92,7 @@ const en: Record<TranslationKey, string> = {
"dashboard.withIsbn": "With ISBN", "dashboard.withIsbn": "With ISBN",
"dashboard.currentlyReading": "Currently reading", "dashboard.currentlyReading": "Currently reading",
"dashboard.recentlyRead": "Recently read", "dashboard.recentlyRead": "Recently read",
"dashboard.readingActivity": "Reading activity (last 12 months)", "dashboard.readingActivity": "Reading activity",
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "No books in progress", "dashboard.noCurrentlyReading": "No books in progress",
"dashboard.noRecentlyRead": "No books read recently", "dashboard.noRecentlyRead": "No books read recently",

View File

@@ -68,7 +68,15 @@ const fr = {
"dashboard.readingStatus": "Statut de lecture", "dashboard.readingStatus": "Statut de lecture",
"dashboard.byFormat": "Par format", "dashboard.byFormat": "Par format",
"dashboard.byLibrary": "Par bibliothèque", "dashboard.byLibrary": "Par bibliothèque",
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)", "dashboard.booksAdded": "Livres ajoutés",
"dashboard.jobsOverTime": "Exécutions de jobs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Autre",
"dashboard.periodDay": "Jour",
"dashboard.periodWeek": "Semaine",
"dashboard.periodMonth": "Mois",
"dashboard.popularSeries": "Séries populaires", "dashboard.popularSeries": "Séries populaires",
"dashboard.noSeries": "Aucune série pour le moment", "dashboard.noSeries": "Aucune série pour le moment",
"dashboard.unknown": "Inconnu", "dashboard.unknown": "Inconnu",
@@ -82,7 +90,7 @@ const fr = {
"dashboard.withIsbn": "Avec ISBN", "dashboard.withIsbn": "Avec ISBN",
"dashboard.currentlyReading": "En cours de lecture", "dashboard.currentlyReading": "En cours de lecture",
"dashboard.recentlyRead": "Derniers livres lus", "dashboard.recentlyRead": "Derniers livres lus",
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)", "dashboard.readingActivity": "Activité de lecture",
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "Aucun livre en cours", "dashboard.noCurrentlyReading": "Aucun livre en cours",
"dashboard.noRecentlyRead": "Aucun livre lu récemment", "dashboard.noRecentlyRead": "Aucun livre lu récemment",

View File

@@ -4,6 +4,7 @@ const nextConfig = {
typedRoutes: true, typedRoutes: true,
images: { images: {
minimumCacheTTL: 86400, minimumCacheTTL: 86400,
unoptimized: true,
}, },
}; };

View File

@@ -1,6 +1,6 @@
{ {
"name": "stripstream-backoffice", "name": "stripstream-backoffice",
"version": "1.24.0", "version": "1.27.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 7082", "dev": "next dev -p 7082",

View File

@@ -170,6 +170,34 @@
--- ---
## Notifications
### Telegram
- Real-time notifications via Telegram Bot API (`sendMessage` and `sendPhoto`)
- Configuration: bot token, chat ID, enable/disable toggle
- Test connection button in settings
### Granular Event Toggles
12 individually configurable notification events grouped by category:
| Category | Events |
|----------|--------|
| Scans | `scan_completed`, `scan_failed`, `scan_cancelled` |
| Thumbnails | `thumbnail_completed`, `thumbnail_failed`, `thumbnail_cancelled` |
| Conversion | `conversion_completed`, `conversion_failed`, `conversion_cancelled` |
| Metadata | `metadata_approved`, `metadata_batch_completed`, `metadata_refresh_completed` |
### Thumbnail Images in Notifications
- Book cover thumbnails attached to applicable notifications (conversion, metadata approval)
- Uses `sendPhoto` multipart upload with fallback to text-only `sendMessage`
### Implementation
- Shared `crates/notifications` crate used by both API and indexer
- Fire-and-forget: notification failures are logged but never block the main operation
- Messages formatted in HTML with event-specific icons
---
## Page Rendering & Caching ## Page Rendering & Caching
### Page Extraction ### Page Extraction
@@ -238,13 +266,16 @@
## Backoffice (Web UI) ## Backoffice (Web UI)
### Dashboard ### Dashboard
- Statistics cards: books, series, authors, libraries - Statistics cards: books, series, authors, libraries, pages, total size
- Donut charts: reading status breakdown, format distribution - Interactive charts (recharts): donut, area, stacked bar, horizontal bar
- Bar charts: books per language - Reading status breakdown, format distribution, library distribution
- Per-library reading progress bars - Currently reading section with progress bars
- Top series by book/page count - Recently read section with cover thumbnails
- Monthly addition timeline - Reading activity over time (area chart)
- Metadata coverage stats - Books added over time (area chart)
- Per-library stacked reading progress
- Top series by book count
- Metadata coverage and provider breakdown
### Pages ### Pages
- **Libraries**: list, create, delete, configure monitoring and metadata provider - **Libraries**: list, create, delete, configure monitoring and metadata provider
@@ -253,7 +284,7 @@
- **Authors**: list with book/series counts, detail with author's books - **Authors**: list with book/series counts, detail with author's books
- **Jobs**: history, live progress via SSE, error details - **Jobs**: history, live progress via SSE, error details
- **Tokens**: create, list, revoke API tokens - **Tokens**: create, list, revoke API tokens
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent) - **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent), notifications (Telegram)
### Interactive Features ### Interactive Features
- Real-time search with suggestions - Real-time search with suggestions