Compare commits

...

3 Commits

Author SHA1 Message Date
648d86970f 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>
2026-03-10 21:53:52 +01:00
278f422206 feat(backoffice): améliorer les détails de job avec historique des phases
- Ajoute migration 0015 : colonne phase2_started_at sur index_jobs
- Indexer : renseigne phase2_started_at lors du passage à generating_thumbnails
- API : expose phase2_started_at et book_id dans IndexJobDetailResponse
- Page détail : timeline avec durée de chaque phase (Discovery / Thumbnails)
- Page détail : banners contextuels (success/failed/cancelled) avec résumé en une ligne
- Page détail : description textuelle du type de job, durée dans l'overview
- Page détail : stats normalisées selon le type (index vs thumbnail-only)
- JobRow : affiche le type via JobTypeBadge (cohérence visuelle)
- Badge : labels lisibles pour tous les types de jobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 16:40:01 +01:00
ff59ac1eff fix(indexer): full_rebuild par library ne supprime plus les thumbnails des autres libraries
cleanup_orphaned_thumbnails chargeait uniquement les book IDs de la library
en cours de rebuild, considérant les thumbnails des autres libraries comme
orphelins et les supprimant. La fonction charge désormais tous les book IDs
toutes libraries confondues.

Ajout d'un test de régression dans infra/smoke.sh qui vérifie que le
full_rebuild d'une library ne réduit pas le nombre de thumbnails des autres.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:52:00 +01:00
24 changed files with 902 additions and 106 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

@@ -55,12 +55,16 @@ pub struct IndexJobDetailResponse {
pub id: Uuid,
#[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>)]
pub book_id: Option<Uuid>,
pub r#type: String,
pub status: String,
#[schema(value_type = Option<String>)]
pub started_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)]
pub finished_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)]
pub phase2_started_at: Option<DateTime<Utc>>,
pub stats_json: Option<serde_json::Value>,
pub error_opt: Option<String>,
#[schema(value_type = String)]
@@ -314,10 +318,12 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse {
IndexJobDetailResponse {
id: row.get("id"),
library_id: row.get("library_id"),
book_id: row.try_get("book_id").ok().flatten(),
r#type: row.get("type"),
status: row.get("status"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
phase2_started_at: row.try_get("phase2_started_at").ok().flatten(),
stats_json: row.get("stats_json"),
error_opt: row.get("error_opt"),
created_at: row.get("created_at"),
@@ -374,8 +380,8 @@ pub async fn get_job_details(
id: axum::extract::Path<Uuid>,
) -> Result<Json<IndexJobDetailResponse>, ApiError> {
let row = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at,
current_file, progress_percent, total_files, processed_files
"SELECT id, library_id, book_id, type, status, started_at, finished_at, phase2_started_at,
stats_json, error_opt, created_at, current_file, progress_percent, total_files, processed_files
FROM index_jobs WHERE id = $1"
)
.bind(id.0)

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"),
}))
}

View File

@@ -1,4 +1,4 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch } from "../../../lib/api";
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import Image from "next/image";
@@ -7,6 +7,37 @@ import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
};
function ReadingStatusBadge({
status,
currentPage,
lastReadAt,
}: {
status: ReadingStatus;
currentPage: number | null;
lastReadAt: string | null;
}) {
const { label, className } = readingStatusConfig[status];
return (
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${className}`}>
{label}
{status === "reading" && currentPage != null && ` · p. ${currentPage}`}
</span>
{lastReadAt && (
<span className="text-xs text-muted-foreground">
{new Date(lastReadAt).toLocaleDateString()}
</span>
)}
</div>
);
}
async function fetchBook(bookId: string): Promise<BookDto | null> {
try {
return await apiFetch<BookDto>(`/books/${bookId}`);
@@ -71,6 +102,17 @@ export default async function BookDetailPage({
)}
<div className="space-y-3">
{book.reading_status && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Lecture :</span>
<ReadingStatusBadge
status={book.reading_status}
currentPage={book.reading_current_page ?? null}
lastReadAt={book.reading_last_read_at ?? null}
/>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Format:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${

View File

@@ -3,10 +3,17 @@
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { BookDto } from "../../lib/api";
import { BookDto, ReadingStatus } from "../../lib/api";
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
unread: null,
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
read: { label: "Lu", className: "bg-green-600/90 text-white" },
};
interface BookCardProps {
book: BookDto & { coverUrl?: string };
readingStatus?: ReadingStatus;
}
function BookImage({ src, alt }: { src: string; alt: string }) {
@@ -37,18 +44,27 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
);
}
export function BookCard({ book }: BookCardProps) {
export function BookCard({ book, readingStatus }: BookCardProps) {
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
const status = readingStatus ?? book.reading_status;
const overlay = status ? readingStatusOverlay[status] : null;
return (
<Link
href={`/books/${book.id}`}
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
>
<div className="relative">
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
/>
{overlay && (
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
{overlay.label}
</span>
)}
</div>
{/* Book Info */}
<div className="p-4">

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { JobProgress } from "./JobProgress";
import { StatusBadge, Button, MiniProgressBar } from "./ui";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
interface JobRowProps {
job: {
@@ -93,8 +93,8 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<td className="px-4 py-3 text-sm text-foreground">
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
</td>
<td className="px-4 py-3 text-sm text-foreground">
{job.type === "cbr_to_cbz" ? "CBR → CBZ" : job.type}
<td className="px-4 py-3">
<JobTypeBadge type={job.type} />
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">

View File

@@ -94,8 +94,11 @@ const jobTypeVariants: Record<string, BadgeVariant> = {
};
const jobTypeLabels: Record<string, string> = {
rebuild: "Index",
full_rebuild: "Full Index",
thumbnail_rebuild: "Thumbnails",
thumbnail_regenerate: "Regenerate",
thumbnail_regenerate: "Regen. Thumbnails",
cbr_to_cbz: "CBR → CBZ",
};
interface JobTypeBadgeProps {

View File

@@ -248,6 +248,29 @@ body::after {
overflow: hidden;
}
/* Reading progress badge variants */
.badge-unread {
background: hsl(var(--color-muted) / 0.6);
color: hsl(var(--color-muted-foreground));
border-color: hsl(var(--color-border));
}
.badge-in-progress {
background: hsl(38 92% 50% / 0.15);
color: hsl(38 92% 40%);
border-color: hsl(38 92% 50% / 0.3);
}
.dark .badge-in-progress {
color: hsl(38 92% 65%);
}
.badge-completed {
background: hsl(var(--color-success) / 0.15);
color: hsl(var(--color-success));
border-color: hsl(var(--color-success) / 0.3);
}
/* Hide scrollbar */
.scrollbar-hide {
-ms-overflow-style: none;

View File

@@ -13,11 +13,13 @@ interface JobDetailPageProps {
interface JobDetails {
id: string;
library_id: string | null;
book_id: string | null;
type: string;
status: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
phase2_started_at: string | null;
current_file: string | null;
progress_percent: number | null;
processed_files: number | null;
@@ -38,6 +40,34 @@ interface JobError {
created_at: string;
}
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
rebuild: {
label: "Incremental index",
description: "Scans for new/modified files, analyzes them and generates missing thumbnails.",
isThumbnailOnly: false,
},
full_rebuild: {
label: "Full re-index",
description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.",
isThumbnailOnly: false,
},
thumbnail_rebuild: {
label: "Thumbnail rebuild",
description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.",
isThumbnailOnly: true,
},
thumbnail_regenerate: {
label: "Thumbnail regeneration",
description: "Regenerates all thumbnails from scratch, replacing existing ones.",
isThumbnailOnly: true,
},
cbr_to_cbz: {
label: "CBR → CBZ conversion",
description: "Converts a CBR archive to the open CBZ format.",
isThumbnailOnly: false,
},
};
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
try {
return await apiFetch<JobDetails>(`/index/jobs/${jobId}`);
@@ -64,10 +94,9 @@ function formatDuration(start: string, end: string | null): string {
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
}
function formatSpeed(stats: { scanned_files: number } | null, duration: number): string {
if (!stats || duration === 0) return "-";
const filesPerSecond = stats.scanned_files / (duration / 1000);
return `${filesPerSecond.toFixed(1)} f/s`;
function formatSpeed(count: number, durationMs: number): string {
if (durationMs === 0 || count === 0) return "-";
return `${(count / (durationMs / 1000)).toFixed(1)}/s`;
}
export default async function JobDetailPage({ params }: JobDetailPageProps) {
@@ -81,10 +110,44 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
notFound();
}
const duration = job.started_at
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
label: job.type,
description: null,
isThumbnailOnly: false,
};
const durationMs = job.started_at
? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime()
: 0;
const isCompleted = job.status === "success";
const isFailed = job.status === "failed";
const isCancelled = job.status === "cancelled";
const isThumbnailPhase = job.status === "generating_thumbnails";
const { isThumbnailOnly } = typeInfo;
// Which label to use for the progress card
const progressTitle = isThumbnailOnly
? "Thumbnails"
: isThumbnailPhase
? "Phase 2 — Thumbnails"
: "Phase 1 — Discovery";
const progressDescription = isThumbnailOnly
? undefined
: isThumbnailPhase
? "Generating thumbnails for the analyzed books"
: "Scanning and indexing files in the library";
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
const speedCount = isThumbnailOnly
? (job.processed_files ?? 0)
: (job.stats_json?.scanned_files ?? 0);
const showProgressCard =
(isCompleted || isFailed || job.status === "running" || isThumbnailPhase) &&
(job.total_files != null || !!job.current_file);
return (
<>
<div className="mb-6">
@@ -100,11 +163,72 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
</div>
{/* Summary banner — completed */}
{isCompleted && job.started_at && (
<div className="mb-6 p-4 rounded-xl bg-success/10 border border-success/30 flex items-start gap-3">
<svg className="w-5 h-5 text-success mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-success">
<span className="font-semibold">Completed in {formatDuration(job.started_at, job.finished_at)}</span>
{job.stats_json && (
<span className="ml-2 text-success/80">
{job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} errors`}
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} thumbnails`}
</span>
)}
{!job.stats_json && isThumbnailOnly && job.total_files != null && (
<span className="ml-2 text-success/80">
{job.processed_files ?? job.total_files} thumbnails generated
</span>
)}
</div>
</div>
)}
{/* Summary banner — failed */}
{isFailed && (
<div className="mb-6 p-4 rounded-xl bg-destructive/10 border border-destructive/30 flex items-start gap-3">
<svg className="w-5 h-5 text-destructive mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-destructive">
<span className="font-semibold">Job failed</span>
{job.started_at && (
<span className="ml-2 text-destructive/80">after {formatDuration(job.started_at, job.finished_at)}</span>
)}
{job.error_opt && (
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
)}
</div>
</div>
)}
{/* Summary banner — cancelled */}
{isCancelled && (
<div className="mb-6 p-4 rounded-xl bg-muted border border-border flex items-start gap-3">
<svg className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<span className="text-sm text-muted-foreground">
<span className="font-semibold">Cancelled</span>
{job.started_at && (
<span className="ml-2">after {formatDuration(job.started_at, job.finished_at)}</span>
)}
</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Overview Card */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
{typeInfo.description && (
<CardDescription>{typeInfo.description}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border/60">
@@ -113,16 +237,38 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</div>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Type</span>
<div className="flex items-center gap-2">
<JobTypeBadge type={job.type} />
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
</div>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Status</span>
<StatusBadge status={job.status} />
</div>
<div className="flex items-center justify-between py-2">
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
<span className="text-sm text-muted-foreground">Library</span>
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
</div>
{job.book_id && (
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
<span className="text-sm text-muted-foreground">Book</span>
<Link
href={`/books/${job.book_id}`}
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
>
{job.book_id.slice(0, 8)}
</Link>
</div>
)}
{job.started_at && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Duration</span>
<span className="text-sm font-semibold text-foreground">
{formatDuration(job.started_at, job.finished_at)}
</span>
</div>
)}
</CardContent>
</Card>
@@ -131,101 +277,194 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
<CardHeader>
<CardTitle>Timeline</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border" />
<div className="space-y-5">
{/* Created */}
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
<div className="flex-1">
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">Created</span>
<p className="text-sm text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
</div>
</div>
{/* Phase 1 start — for index jobs that have two phases */}
{job.started_at && job.phase2_started_at && (
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Started</span>
<p className="text-sm text-muted-foreground">
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">Phase 1 Discovery</span>
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
<p className="text-xs text-primary/80 font-medium mt-0.5">
Duration: {formatDuration(job.started_at, job.phase2_started_at)}
{job.stats_json && (
<span className="text-muted-foreground font-normal ml-1">
· {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
</span>
)}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Finished</span>
<p className="text-sm text-muted-foreground">
{job.finished_at
? new Date(job.finished_at).toLocaleString()
: job.started_at
? "Running..."
: "Waiting..."
}
</p>
</div>
</div>
{job.started_at && (
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
Duration: {formatDuration(job.started_at, job.finished_at)}
</div>
)}
{/* Phase 2 start — for index jobs that have two phases */}
{job.phase2_started_at && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">
{isThumbnailOnly ? "Thumbnails" : "Phase 2 — Thumbnails"}
</span>
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
{job.finished_at && (
<p className="text-xs text-primary/80 font-medium mt-0.5">
Duration: {formatDuration(job.phase2_started_at, job.finished_at)}
{job.total_files != null && job.total_files > 0 && (
<span className="text-muted-foreground font-normal ml-1">
· {job.processed_files ?? job.total_files} thumbnails
</span>
)}
</p>
)}
</div>
</div>
)}
{/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */}
{job.started_at && !job.phase2_started_at && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">Started</span>
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
</div>
</div>
)}
{/* Pending — not started yet */}
{!job.started_at && (
<div className="flex items-start gap-4">
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">Waiting to start</span>
</div>
</div>
)}
{/* Finished */}
{job.finished_at && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
isCompleted ? "bg-success" : isFailed ? "bg-destructive" : "bg-muted"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}
</span>
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Progress Card */}
{(job.status === "running" || job.status === "generating_thumbnails" || job.status === "success" || job.status === "failed") && (
{showProgressCard && (
<Card>
<CardHeader>
<CardTitle>{job.status === "generating_thumbnails" ? "Thumbnails" : "Progress"}</CardTitle>
<CardTitle>{progressTitle}</CardTitle>
{progressDescription && <CardDescription>{progressDescription}</CardDescription>}
</CardHeader>
<CardContent>
{job.total_files != null && job.total_files > 0 && (
<>
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
<div className="grid grid-cols-3 gap-4">
<StatBox value={job.processed_files ?? 0} label="Processed" variant="primary" />
<StatBox value={job.total_files} label={job.status === "generating_thumbnails" ? "Total thumbnails" : "Total"} />
<StatBox value={job.total_files - (job.processed_files ?? 0)} label="Remaining" variant="warning" />
<StatBox
value={job.processed_files ?? 0}
label={isThumbnailOnly || isThumbnailPhase ? "Generated" : "Processed"}
variant="primary"
/>
<StatBox value={job.total_files} label="Total" />
<StatBox
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
label="Remaining"
variant={isCompleted ? "default" : "warning"}
/>
</div>
</>
)}
{job.current_file && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
<span className="text-sm text-muted-foreground">Current file:</span>
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
<span className="text-xs text-muted-foreground uppercase tracking-wide">Current file</span>
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
</div>
)}
</CardContent>
</Card>
)}
{/* Statistics Card */}
{job.stats_json && (
{/* Index Statistics — index jobs only */}
{job.stats_json && !isThumbnailOnly && (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
<CardTitle>Index statistics</CardTitle>
{job.started_at && (
<CardDescription>
{formatDuration(job.started_at, job.finished_at)}
{speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
</div>
{job.started_at && (
<div className="flex items-center justify-between py-2 border-t border-border/60">
<span className="text-sm text-muted-foreground">Speed:</span>
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Errors Card */}
{/* Thumbnail statistics — thumbnail-only jobs, completed */}
{isThumbnailOnly && isCompleted && job.total_files != null && (
<Card>
<CardHeader>
<CardTitle>Thumbnail statistics</CardTitle>
{job.started_at && (
<CardDescription>
{formatDuration(job.started_at, job.finished_at)}
{speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<StatBox value={job.processed_files ?? job.total_files} label="Generated" variant="success" />
<StatBox value={job.total_files} label="Total" />
</div>
</CardContent>
</Card>
)}
{/* File errors */}
{errors.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Errors ({errors.length})</CardTitle>
<CardDescription>Errors encountered during job execution</CardDescription>
<CardTitle>File errors ({errors.length})</CardTitle>
<CardDescription>Errors encountered while processing individual files</CardDescription>
</CardHeader>
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
{errors.map((error) => (
@@ -238,19 +477,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</CardContent>
</Card>
)}
{/* Error Message */}
{job.error_opt && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Job failed with error</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-destructive/10 rounded-lg text-sm text-destructive overflow-x-auto border border-destructive/20">{job.error_opt}</pre>
</CardContent>
</Card>
)}
</div>
</>
);

View File

@@ -46,6 +46,14 @@ export type FolderItem = {
has_children: boolean;
};
export type ReadingStatus = "unread" | "reading" | "read";
export type ReadingProgressDto = {
status: ReadingStatus;
current_page: number | null;
last_read_at: string | null;
};
export type BookDto = {
id: string;
library_id: string;
@@ -60,6 +68,10 @@ export type BookDto = {
file_format: string | null;
file_parse_status: string | null;
updated_at: string;
// Présents uniquement sur GET /books/:id (pas dans la liste)
reading_status?: ReadingStatus;
reading_current_page?: number | null;
reading_last_read_at?: string | null;
};
export type BooksPageDto = {
@@ -353,3 +365,18 @@ export async function getThumbnailStats() {
export async function convertBook(bookId: string) {
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
}
export async function fetchReadingProgress(bookId: string) {
return apiFetch<ReadingProgressDto>(`/books/${bookId}/progress`);
}
export async function updateReadingProgress(
bookId: string,
status: ReadingStatus,
currentPage?: number,
) {
return apiFetch<ReadingProgressDto>(`/books/${bookId}/progress`, {
method: "PATCH",
body: JSON.stringify({ status, current_page: currentPage ?? null }),
});
}

View File

@@ -388,16 +388,14 @@ pub async fn regenerate_thumbnails(
}
/// Delete orphaned thumbnail files (books deleted in full_rebuild get new UUIDs).
pub async fn cleanup_orphaned_thumbnails(
state: &AppState,
library_id: Option<Uuid>,
) -> Result<()> {
pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> Result<()> {
let config = load_thumbnail_config(&state.pool).await;
// Load ALL book IDs across all libraries — we need the complete set to avoid
// deleting thumbnails that belong to other libraries during a per-library rebuild.
let existing_book_ids: std::collections::HashSet<Uuid> = sqlx::query_scalar(
r#"SELECT id FROM books WHERE (library_id = $1 OR $1 IS NULL)"#,
r#"SELECT id FROM books"#,
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default()

View File

@@ -157,7 +157,7 @@ pub async fn process_job(
// Thumbnail rebuild: generate thumbnails for books missing them
if job_type == "thumbnail_rebuild" {
sqlx::query(
"UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW() WHERE id = $1",
"UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW(), phase2_started_at = NOW() WHERE id = $1",
)
.bind(job_id)
.execute(&state.pool)
@@ -178,7 +178,7 @@ pub async fn process_job(
// Thumbnail regenerate: clear all thumbnails then re-generate
if job_type == "thumbnail_regenerate" {
sqlx::query(
"UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW() WHERE id = $1",
"UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW(), phase2_started_at = NOW() WHERE id = $1",
)
.bind(job_id)
.execute(&state.pool)
@@ -315,12 +315,12 @@ pub async fn process_job(
// For full rebuild: clean up orphaned thumbnail files (old UUIDs)
if is_full_rebuild {
analyzer::cleanup_orphaned_thumbnails(state, target_library_id).await?;
analyzer::cleanup_orphaned_thumbnails(state).await?;
}
// Phase 2: Analysis (extract page_count + thumbnails for new/updated books)
sqlx::query(
"UPDATE index_jobs SET status = 'generating_thumbnails', stats_json = $2, current_file = NULL, processed_files = $3 WHERE id = $1",
"UPDATE index_jobs SET status = 'generating_thumbnails', phase2_started_at = NOW(), stats_json = $2, current_file = NULL, processed_files = $3 WHERE id = $1",
)
.bind(job_id)
.bind(serde_json::to_value(&stats)?)

View File

@@ -0,0 +1,2 @@
ALTER TABLE index_jobs
ADD COLUMN phase2_started_at TIMESTAMPTZ;

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS book_reading_progress (
book_id UUID PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('unread', 'reading', 'read')) DEFAULT 'unread',
current_page INT,
last_read_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_book_reading_progress_status ON book_reading_progress(status);

View File

@@ -122,6 +122,62 @@ else
exit 1
fi
# ─── full_rebuild par library ne casse pas les thumbnails des autres ──────────
#
# Régression : cleanup_orphaned_thumbnails chargeait uniquement les book IDs
# de la library en cours de rebuild, supprimant les thumbnails des autres.
#
# Ce test nécessite au moins 2 libraries activées avec des livres indexés.
echo "[smoke] test: full_rebuild per-library does not destroy other libraries thumbnails"
LIBRARIES_JSON="$(auth "$BASE_API/libraries")"
LIBRARY_COUNT="$(LIBRARIES_JSON="$LIBRARIES_JSON" python3 -c "import json,os; libs=json.loads(os.environ['LIBRARIES_JSON']); print(len(libs) if isinstance(libs,list) else 0)")"
if [ "${LIBRARY_COUNT:-0}" -lt 2 ]; then
echo "[smoke] SKIP: need at least 2 libraries (found ${LIBRARY_COUNT:-0})"
else
# Extraire les 2 premiers IDs de library
LIB_A="$(LIBRARIES_JSON="$LIBRARIES_JSON" python3 -c "import json,os; libs=json.loads(os.environ['LIBRARIES_JSON']); print(libs[0]['id'])")"
LIB_B="$(LIBRARIES_JSON="$LIBRARIES_JSON" python3 -c "import json,os; libs=json.loads(os.environ['LIBRARIES_JSON']); print(libs[1]['id'])")"
echo "[smoke] library A = $LIB_A, library B = $LIB_B"
# Compter les thumbnails de library B avant le rebuild de A
BOOKS_B_BEFORE="$(auth "$BASE_API/books?library_id=$LIB_B")"
THUMBS_BEFORE="$(BOOKS_B_BEFORE="$BOOKS_B_BEFORE" python3 -c "
import json, os
items = json.loads(os.environ['BOOKS_B_BEFORE']).get('items') or []
print(sum(1 for b in items if b.get('thumbnail_url')))
")"
echo "[smoke] library B: $THUMBS_BEFORE book(s) with thumbnail before rebuild of A"
if [ "${THUMBS_BEFORE:-0}" -eq 0 ]; then
echo "[smoke] SKIP: library B has no thumbnails to protect, test not meaningful"
else
# Lancer un full_rebuild sur library A uniquement
REBUILD_A_ID="$(auth -X POST -H "Content-Type: application/json" \
-d "{\"library_id\":\"$LIB_A\",\"full\":true}" \
"$BASE_API/index/rebuild" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")"
echo "[smoke] full_rebuild library A job id: $REBUILD_A_ID"
wait_job "$REBUILD_A_ID" "full_rebuild library A"
# Vérifier que les thumbnails de library B sont intacts
BOOKS_B_AFTER="$(auth "$BASE_API/books?library_id=$LIB_B")"
THUMBS_AFTER="$(BOOKS_B_AFTER="$BOOKS_B_AFTER" python3 -c "
import json, os
items = json.loads(os.environ['BOOKS_B_AFTER']).get('items') or []
print(sum(1 for b in items if b.get('thumbnail_url')))
")"
echo "[smoke] library B: $THUMBS_AFTER book(s) with thumbnail after rebuild of A"
if [ "$THUMBS_AFTER" -lt "$THUMBS_BEFORE" ]; then
echo "[smoke] FAIL: full_rebuild of library A destroyed thumbnails of library B ($THUMBS_BEFORE$THUMBS_AFTER)"
exit 1
else
echo "[smoke] OK: library B thumbnails preserved ($THUMBS_BEFORE$THUMBS_AFTER)"
fi
fi
fi
# ─── metrics ─────────────────────────────────────────────────────────────────
echo "[smoke] metrics"

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-10

View File

@@ -0,0 +1,66 @@
## Context
L'API est une application axum single-user protégée par tokens (`admin` / `read`). Les données sont dans PostgreSQL, gérées via sqlx. Les routes existantes sont groupées en `admin_routes` et `read_routes` dans `main.rs`. La table `books` stocke uniquement les métadonnées de contenu (titre, auteur, pages, etc.) sans aucune notion d'état de lecture.
Le `GET /books/:id` utilise déjà un `LEFT JOIN LATERAL` pour les `book_files` — le même pattern s'applique naturellement pour la progression de lecture.
## Goals / Non-Goals
**Goals:**
- Stocker et exposer l'état de lecture (unread / reading / read) par livre
- Mémoriser la page courante pour reprendre la lecture
- Horodater la dernière activité de lecture
- Enrichir `GET /books/:id` sans breaking change
- Documenter tous les endpoints dans Swagger via utoipa
**Non-Goals:**
- Support multi-utilisateur (pas de concept d'utilisateur dans l'app)
- Historique des sessions de lecture
- Synchronisation entre clients
- Progression sur `GET /books` (liste paginée — impact perf non justifié)
## Decisions
### D1 — Table séparée `book_reading_progress` (vs colonnes sur `books`)
`book_reading_progress` avec `book_id` comme PRIMARY KEY (relation 1-to-1).
**Rationale** : sépare les métadonnées de contenu (immuables, issues de l'indexer) des données de lecture (mutables, issues de l'utilisateur). Facilite les requêtes ciblées et isole les permissions futures si multi-user.
**Alternative rejetée** : colonnes directes sur `books` — plus simple mais mélange deux responsabilités différentes dans la même table.
### D2 — Upsert sur PATCH (INSERT ... ON CONFLICT DO UPDATE)
Une seule ligne par livre, créée à la première mise à jour. Pas de `created_at` dans la table.
**Rationale** : évite un GET + INSERT/UPDATE en deux temps. La sémantique "créer si absent" est transparente pour le client.
### D3 — Token `read` autorisé à écrire la progression
Le PATCH est ajouté dans une section de `main.rs` protégée par `require_read` (pas `require_admin`).
**Rationale** : le cas d'usage principal est une app de lecture tournant avec un token read-only. Exiger un token admin serait inutilement contraignant pour une opération purement personnelle.
### D4 — Réponse par défaut si progression inexistante
`GET /books/:id/progress` retourne `{ "status": "unread", "current_page": null, "last_read_at": null }` si aucune ligne n'existe, plutôt qu'un 404.
**Rationale** : simplifie les clients — pas besoin de gérer un 404 comme état "non lu". L'absence de progression EST l'état "unread".
### D5 — `current_page` : valeur positive sans validation contre `page_count`
Accepter toute valeur `> 0` sans vérifier que la page existe dans le livre.
**Rationale** : `page_count` peut être NULL (phase d'analyse pas encore terminée). La validation côté client est plus appropriée.
## Risks / Trade-offs
- **Pas de validation de page** → un client peut enregistrer `current_page = 9999` sur un livre de 10 pages. Risque faible dans un contexte single-user personnel.
- **Token read en écriture** → légère déviation du principe least-privilege. Acceptable car la progression n'affecte pas les autres données.
- **LEFT JOIN dans `GET /books/:id`** → requête légèrement plus lourde. Impact négligeable (1 row lookup sur clé primaire).
## Migration Plan
1. Déployer la migration `0016_add_reading_progress.sql` (non-destructive, nouvelle table)
2. Déployer le binaire API avec les nouveaux endpoints
3. Rollback : supprimer la table et redéployer l'ancienne version (aucune donnée critique perdue)

View File

@@ -0,0 +1,29 @@
## Why
L'application ne permet pas de suivre la progression de lecture d'un livre. Il est impossible de savoir quels livres ont été lus, lesquels sont en cours, ou de mémoriser la page courante pour reprendre là où on s'est arrêté.
## What Changes
- Nouvelle table PostgreSQL `book_reading_progress` pour stocker l'état de lecture par livre
- Nouvel endpoint `PATCH /books/:id/progress` pour mettre à jour la progression (accessible avec token `read` ou `admin`)
- Nouvel endpoint `GET /books/:id/progress` pour consulter la progression d'un livre
- Enrichissement de `GET /books/:id` avec les champs de progression (`reading_status`, `reading_current_page`, `reading_last_read_at`)
- Documentation Swagger complète via utoipa pour tous les nouveaux endpoints et schemas
## Capabilities
### New Capabilities
- `reading-progress`: Suivi de l'état de lecture d'un livre (unread / reading / read), avec mémorisation de la page courante et horodatage de la dernière lecture
### Modified Capabilities
- `book-details`: Le détail d'un livre (`GET /books/:id`) expose désormais les informations de progression de lecture
## Impact
- **DB** : nouvelle migration `0016_add_reading_progress.sql`
- **API** : nouveau module `apps/api/src/reading_progress.rs` avec 2 handlers axum
- **API** : `apps/api/src/books.rs``BookDetails` enrichi + requête SQL modifiée (LEFT JOIN)
- **API** : `apps/api/src/main.rs` — ajout des routes dans `read_routes` (GET) et dans une section accessible au token `read` (PATCH)
- **API** : `apps/api/src/openapi.rs` — enregistrement des nouveaux composants Swagger

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Consulter le détail d'un livre
Le système SHALL retourner les détails d'un livre via `GET /books/:id`, incluant désormais les informations de progression de lecture : `reading_status` (valeur par défaut `"unread"`), `reading_current_page` (nullable), et `reading_last_read_at` (nullable).
#### Scenario: Livre sans progression enregistrée
- **WHEN** le client appelle `GET /books/:id` pour un livre sans progression
- **THEN** le système retourne HTTP 200 avec les champs de progression à leurs valeurs par défaut : `reading_status = "unread"`, `reading_current_page = null`, `reading_last_read_at = null`
#### Scenario: Livre avec progression en cours
- **WHEN** le client appelle `GET /books/:id` pour un livre dont la progression est `reading`
- **THEN** le système retourne HTTP 200 avec `reading_status = "reading"`, `reading_current_page = <n>`, `reading_last_read_at = <timestamp>`
#### Scenario: Livre inexistant
- **WHEN** le client appelle `GET /books/:id` avec un UUID inexistant
- **THEN** le système retourne HTTP 404

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Consulter la progression de lecture d'un livre
Le système SHALL retourner la progression de lecture d'un livre via `GET /books/:id/progress`. Si aucune progression n'a été enregistrée, le système SHALL retourner `{ "status": "unread", "current_page": null, "last_read_at": null }` sans erreur 404.
#### Scenario: Progression inexistante
- **WHEN** le client appelle `GET /books/:id/progress` pour un livre sans progression enregistrée
- **THEN** le système retourne HTTP 200 avec `{ "status": "unread", "current_page": null, "last_read_at": null }`
#### Scenario: Progression existante — livre en cours
- **WHEN** le client appelle `GET /books/:id/progress` pour un livre avec `status = "reading"`
- **THEN** le système retourne HTTP 200 avec `{ "status": "reading", "current_page": <n>, "last_read_at": <timestamp> }`
#### Scenario: Livre inexistant
- **WHEN** le client appelle `GET /books/:id/progress` avec un UUID invalide ou inexistant
- **THEN** le système retourne HTTP 404
### Requirement: Mettre à jour la progression de lecture
Le système SHALL permettre de mettre à jour la progression de lecture via `PATCH /books/:id/progress`. Cette route SHALL être accessible avec un token `read` ou `admin`.
#### Scenario: Marquer un livre comme lu
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "read" }`
- **THEN** le système enregistre `status = "read"`, `current_page = null`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour
#### Scenario: Marquer un livre comme en cours avec page courante
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading", "current_page": 42 }`
- **THEN** le système enregistre `status = "reading"`, `current_page = 42`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour
#### Scenario: Réinitialiser la progression
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "unread" }`
- **THEN** le système enregistre `status = "unread"`, `current_page = null`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour
#### Scenario: current_page manquant pour status reading
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading" }` sans `current_page`
- **THEN** le système retourne HTTP 422 avec un message d'erreur
#### Scenario: current_page invalide (zéro ou négatif)
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading", "current_page": 0 }`
- **THEN** le système retourne HTTP 422 avec un message d'erreur
#### Scenario: current_page ignoré pour status non-reading
- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "read", "current_page": 42 }`
- **THEN** le système enregistre `current_page = null` (la valeur fournie est ignorée)
#### Scenario: Livre inexistant
- **WHEN** le client envoie `PATCH /books/:id/progress` avec un UUID de livre inexistant
- **THEN** le système retourne HTTP 404
### Requirement: last_read_at mis à jour à chaque modification
Le système SHALL mettre à jour `last_read_at` à l'horodatage courant à chaque appel `PATCH /books/:id/progress`, quel que soit le statut.
#### Scenario: last_read_at actualisé sur tout changement
- **WHEN** le client envoie `PATCH /books/:id/progress` avec n'importe quel statut valide
- **THEN** `last_read_at` dans la réponse est égal à l'heure de la requête (NOW())

View File

@@ -0,0 +1,28 @@
## 1. Migration DB
- [x] 1.1 Créer `infra/migrations/0016_add_reading_progress.sql` avec la table `book_reading_progress` (book_id PK, status TEXT CHECK, current_page INT nullable, last_read_at TIMESTAMPTZ nullable, updated_at TIMESTAMPTZ)
- [x] 1.2 Ajouter un index sur `book_reading_progress(status)` pour faciliter les futurs filtres
## 2. Module reading_progress.rs
- [x] 2.1 Créer `apps/api/src/reading_progress.rs` avec les structs `ReadingProgressResponse` et `UpdateReadingProgressRequest` (avec ToSchema pour Swagger)
- [x] 2.2 Implémenter le handler `get_reading_progress` : vérifier que le livre existe (404 si non), LEFT JOIN sur `book_reading_progress`, retourner la valeur par défaut `unread` si absent
- [x] 2.3 Implémenter le handler `update_reading_progress` : vérifier que le livre existe (404 si non), valider `current_page > 0` si `status = "reading"` (422 sinon), upsert via `INSERT ... ON CONFLICT DO UPDATE`
- [x] 2.4 Ajouter les annotations `#[utoipa::path(...)]` sur les deux handlers (tag "reading-progress", params, request body, responses 200/404/422/401, security Bearer)
## 3. Enrichissement GET /books/:id
- [x] 3.1 Dans `apps/api/src/books.rs`, ajouter les champs `reading_status`, `reading_current_page`, `reading_last_read_at` à la struct `BookDetails` (avec ToSchema)
- [x] 3.2 Modifier la requête SQL de `get_book` pour inclure un `LEFT JOIN book_reading_progress brp ON brp.book_id = b.id` et mapper les champs (valeur par défaut `"unread"` via COALESCE)
## 4. Routes dans main.rs
- [x] 4.1 Déclarer le module `reading_progress` dans `apps/api/src/main.rs`
- [x] 4.2 Ajouter `GET /books/:id/progress` dans `read_routes`
- [x] 4.3 Ajouter `PATCH /books/:id/progress` dans `read_routes` (accessible token read et admin)
## 5. Swagger / OpenAPI
- [x] 5.1 Dans `apps/api/src/openapi.rs`, enregistrer `ReadingProgressResponse`, `UpdateReadingProgressRequest` dans les `components(schemas(...))`
- [x] 5.2 Enregistrer les paths `get_reading_progress` et `update_reading_progress` dans `paths(...)`
- [x] 5.3 Vérifier que `BookDetails` mis à jour est correctement reflété dans le Swagger généré