feat: suivi de la progression de lecture par livre

- API : nouvelle table book_reading_progress (migration 0016) et module
  reading_progress.rs avec GET/PATCH /books/:id/progress (token read)
- API : GET /books/:id enrichi avec reading_status, reading_current_page,
  reading_last_read_at via LEFT JOIN
- Backoffice : badge de statut (Non lu / En cours · p.N / Lu) sur la page
  de détail et overlay sur les BookCards
- OpenSpec : change reading-progress avec proposal/design/specs/tasks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 21:53:52 +01:00
parent 278f422206
commit 648d86970f
16 changed files with 516 additions and 11 deletions

View File

@@ -63,6 +63,11 @@ pub struct BookDetails {
pub file_path: Option<String>,
pub file_format: Option<String>,
pub file_parse_status: Option<String>,
/// Reading status: "unread", "reading", or "read"
pub reading_status: String,
pub reading_current_page: Option<i32>,
#[schema(value_type = Option<String>)]
pub reading_last_read_at: Option<DateTime<Utc>>,
}
/// List books with optional filtering and pagination
@@ -189,7 +194,10 @@ pub async fn get_book(
let row = sqlx::query(
r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
bf.abs_path, bf.format, bf.parse_status
bf.abs_path, bf.format, bf.parse_status,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN LATERAL (
SELECT abs_path, format, parse_status
@@ -198,6 +206,7 @@ pub async fn get_book(
ORDER BY updated_at DESC
LIMIT 1
) bf ON TRUE
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE b.id = $1
"#,
)
@@ -221,6 +230,9 @@ pub async fn get_book(
file_path: row.get("abs_path"),
file_format: row.get("format"),
file_parse_status: row.get("parse_status"),
reading_status: row.get("reading_status"),
reading_current_page: row.get("reading_current_page"),
reading_last_read_at: row.get("reading_last_read_at"),
}))
}

View File

@@ -38,6 +38,13 @@ impl ApiError {
}
}
pub fn unprocessable_entity(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNPROCESSABLE_ENTITY,
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,

View File

@@ -7,6 +7,7 @@ mod libraries;
mod api_middleware;
mod openapi;
mod pages;
mod reading_progress;
mod search;
mod settings;
mod state;
@@ -106,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
.route("/books/:id", get(books::get_book))
.route("/books/:id/thumbnail", get(books::get_thumbnail))
.route("/books/:id/pages/:n", get(pages::get_page))
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
.route("/libraries/:library_id/series", get(books::list_series))
.route("/search", get(search::search_books))
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))

View File

@@ -6,6 +6,8 @@ use utoipa::OpenApi;
paths(
crate::books::list_books,
crate::books::get_book,
crate::reading_progress::get_reading_progress,
crate::reading_progress::update_reading_progress,
crate::books::get_thumbnail,
crate::books::list_series,
crate::books::convert_book,
@@ -42,6 +44,8 @@ use utoipa::OpenApi;
crate::books::BookItem,
crate::books::BooksPage,
crate::books::BookDetails,
crate::reading_progress::ReadingProgressResponse,
crate::reading_progress::UpdateReadingProgressRequest,
crate::books::SeriesItem,
crate::books::SeriesPage,
crate::pages::PageQuery,
@@ -72,6 +76,7 @@ use utoipa::OpenApi;
),
tags(
(name = "books", description = "Read-only endpoints for browsing and searching books"),
(name = "reading-progress", description = "Reading progress tracking per book"),
(name = "libraries", description = "Library management endpoints (Admin only)"),
(name = "indexing", description = "Search index management and job control (Admin only)"),
(name = "tokens", description = "API token management (Admin only)"),

View File

@@ -0,0 +1,167 @@
use axum::{extract::{Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct ReadingProgressResponse {
/// Reading status: "unread", "reading", or "read"
pub status: String,
/// Current page (only set when status is "reading")
pub current_page: Option<i32>,
#[schema(value_type = Option<String>)]
pub last_read_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateReadingProgressRequest {
/// Reading status: "unread", "reading", or "read"
pub status: String,
/// Required when status is "reading", must be > 0
pub current_page: Option<i32>,
}
/// Get reading progress for a book
#[utoipa::path(
get,
path = "/books/{id}/progress",
tag = "reading-progress",
params(
("id" = String, Path, description = "Book UUID"),
),
responses(
(status = 200, body = ReadingProgressResponse),
(status = 404, description = "Book not found"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_reading_progress(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
// Verify book exists
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
.bind(id)
.fetch_one(&state.pool)
.await?;
if !exists {
return Err(ApiError::not_found("book not found"));
}
let row = sqlx::query(
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
.await?;
let response = match row {
Some(r) => ReadingProgressResponse {
status: r.get("status"),
current_page: r.get("current_page"),
last_read_at: r.get("last_read_at"),
},
None => ReadingProgressResponse {
status: "unread".to_string(),
current_page: None,
last_read_at: None,
},
};
Ok(Json(response))
}
/// Update reading progress for a book
#[utoipa::path(
patch,
path = "/books/{id}/progress",
tag = "reading-progress",
params(
("id" = String, Path, description = "Book UUID"),
),
request_body = UpdateReadingProgressRequest,
responses(
(status = 200, body = ReadingProgressResponse),
(status = 404, description = "Book not found"),
(status = 422, description = "Validation error (missing or invalid current_page for status 'reading')"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn update_reading_progress(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateReadingProgressRequest>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
// Validate status value
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(format!(
"invalid status '{}': must be one of unread, reading, read",
body.status
)));
}
// Validate current_page for "reading" status
if body.status == "reading" {
match body.current_page {
None => {
return Err(ApiError::unprocessable_entity(
"current_page is required when status is 'reading'",
))
}
Some(p) if p <= 0 => {
return Err(ApiError::unprocessable_entity(
"current_page must be greater than 0",
))
}
_ => {}
}
}
// Verify book exists
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
.bind(id)
.fetch_one(&state.pool)
.await?;
if !exists {
return Err(ApiError::not_found("book not found"));
}
// current_page is only stored for "reading" status
let current_page = if body.status == "reading" {
body.current_page
} else {
None
};
let row = sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (book_id) DO UPDATE
SET status = EXCLUDED.status,
current_page = EXCLUDED.current_page,
last_read_at = NOW(),
updated_at = NOW()
RETURNING status, current_page, last_read_at
"#,
)
.bind(id)
.bind(&body.status)
.bind(current_page)
.fetch_one(&state.pool)
.await?;
Ok(Json(ReadingProgressResponse {
status: row.get("status"),
current_page: row.get("current_page"),
last_read_at: row.get("last_read_at"),
}))
}