Compare commits
3 Commits
7eb9e2dcad
...
648d86970f
| Author | SHA1 | Date | |
|---|---|---|---|
| 648d86970f | |||
| 278f422206 | |||
| ff59ac1eff |
@@ -63,6 +63,11 @@ pub struct BookDetails {
|
|||||||
pub file_path: Option<String>,
|
pub file_path: Option<String>,
|
||||||
pub file_format: Option<String>,
|
pub file_format: Option<String>,
|
||||||
pub file_parse_status: 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
|
/// List books with optional filtering and pagination
|
||||||
@@ -189,7 +194,10 @@ pub async fn get_book(
|
|||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
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,
|
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
|
FROM books b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT abs_path, format, parse_status
|
SELECT abs_path, format, parse_status
|
||||||
@@ -198,6 +206,7 @@ pub async fn get_book(
|
|||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) bf ON TRUE
|
) bf ON TRUE
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
WHERE b.id = $1
|
WHERE b.id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -221,6 +230,9 @@ pub async fn get_book(
|
|||||||
file_path: row.get("abs_path"),
|
file_path: row.get("abs_path"),
|
||||||
file_format: row.get("format"),
|
file_format: row.get("format"),
|
||||||
file_parse_status: row.get("parse_status"),
|
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"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
status: StatusCode::NOT_FOUND,
|
status: StatusCode::NOT_FOUND,
|
||||||
|
|||||||
@@ -55,12 +55,16 @@ pub struct IndexJobDetailResponse {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub book_id: Option<Uuid>,
|
||||||
pub r#type: String,
|
pub r#type: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub started_at: Option<DateTime<Utc>>,
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub finished_at: Option<DateTime<Utc>>,
|
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 stats_json: Option<serde_json::Value>,
|
||||||
pub error_opt: Option<String>,
|
pub error_opt: Option<String>,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
@@ -314,10 +318,12 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse {
|
|||||||
IndexJobDetailResponse {
|
IndexJobDetailResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
|
book_id: row.try_get("book_id").ok().flatten(),
|
||||||
r#type: row.get("type"),
|
r#type: row.get("type"),
|
||||||
status: row.get("status"),
|
status: row.get("status"),
|
||||||
started_at: row.get("started_at"),
|
started_at: row.get("started_at"),
|
||||||
finished_at: row.get("finished_at"),
|
finished_at: row.get("finished_at"),
|
||||||
|
phase2_started_at: row.try_get("phase2_started_at").ok().flatten(),
|
||||||
stats_json: row.get("stats_json"),
|
stats_json: row.get("stats_json"),
|
||||||
error_opt: row.get("error_opt"),
|
error_opt: row.get("error_opt"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
@@ -374,8 +380,8 @@ pub async fn get_job_details(
|
|||||||
id: axum::extract::Path<Uuid>,
|
id: axum::extract::Path<Uuid>,
|
||||||
) -> Result<Json<IndexJobDetailResponse>, ApiError> {
|
) -> Result<Json<IndexJobDetailResponse>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at,
|
"SELECT id, library_id, book_id, type, status, started_at, finished_at, phase2_started_at,
|
||||||
current_file, progress_percent, total_files, processed_files
|
stats_json, error_opt, created_at, current_file, progress_percent, total_files, processed_files
|
||||||
FROM index_jobs WHERE id = $1"
|
FROM index_jobs WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id.0)
|
.bind(id.0)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod libraries;
|
|||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
@@ -106,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/books/:id", get(books::get_book))
|
.route("/books/:id", get(books::get_book))
|
||||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.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("/libraries/:library_id/series", get(books::list_series))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use utoipa::OpenApi;
|
|||||||
paths(
|
paths(
|
||||||
crate::books::list_books,
|
crate::books::list_books,
|
||||||
crate::books::get_book,
|
crate::books::get_book,
|
||||||
|
crate::reading_progress::get_reading_progress,
|
||||||
|
crate::reading_progress::update_reading_progress,
|
||||||
crate::books::get_thumbnail,
|
crate::books::get_thumbnail,
|
||||||
crate::books::list_series,
|
crate::books::list_series,
|
||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
@@ -42,6 +44,8 @@ use utoipa::OpenApi;
|
|||||||
crate::books::BookItem,
|
crate::books::BookItem,
|
||||||
crate::books::BooksPage,
|
crate::books::BooksPage,
|
||||||
crate::books::BookDetails,
|
crate::books::BookDetails,
|
||||||
|
crate::reading_progress::ReadingProgressResponse,
|
||||||
|
crate::reading_progress::UpdateReadingProgressRequest,
|
||||||
crate::books::SeriesItem,
|
crate::books::SeriesItem,
|
||||||
crate::books::SeriesPage,
|
crate::books::SeriesPage,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
@@ -72,6 +76,7 @@ use utoipa::OpenApi;
|
|||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
(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 = "libraries", description = "Library management endpoints (Admin only)"),
|
||||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||||
(name = "tokens", description = "API token management (Admin only)"),
|
(name = "tokens", description = "API token management (Admin only)"),
|
||||||
|
|||||||
167
apps/api/src/reading_progress.rs
Normal file
167
apps/api/src/reading_progress.rs
Normal 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"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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 { BookPreview } from "../../components/BookPreview";
|
||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "../../components/ConvertButton";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -7,6 +7,37 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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> {
|
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<BookDto>(`/books/${bookId}`);
|
return await apiFetch<BookDto>(`/books/${bookId}`);
|
||||||
@@ -71,6 +102,17 @@ export default async function BookDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Format:</span>
|
<span className="text-sm text-muted-foreground">Format:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 {
|
interface BookCardProps {
|
||||||
book: BookDto & { coverUrl?: string };
|
book: BookDto & { coverUrl?: string };
|
||||||
|
readingStatus?: ReadingStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
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 coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||||
|
const status = readingStatus ?? book.reading_status;
|
||||||
|
const overlay = status ? readingStatusOverlay[status] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/books/${book.id}`}
|
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"
|
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
|
<BookImage
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
alt={`Cover of ${book.title}`}
|
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 */}
|
{/* Book Info */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
import { StatusBadge, Button, MiniProgressBar } from "./ui";
|
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -93,8 +93,8 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
<td className="px-4 py-3 text-sm text-foreground">
|
<td className="px-4 py-3 text-sm text-foreground">
|
||||||
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
|
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-foreground">
|
<td className="px-4 py-3">
|
||||||
{job.type === "cbr_to_cbz" ? "CBR → CBZ" : job.type}
|
<JobTypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -94,8 +94,11 @@ const jobTypeVariants: Record<string, BadgeVariant> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jobTypeLabels: Record<string, string> = {
|
const jobTypeLabels: Record<string, string> = {
|
||||||
|
rebuild: "Index",
|
||||||
|
full_rebuild: "Full Index",
|
||||||
thumbnail_rebuild: "Thumbnails",
|
thumbnail_rebuild: "Thumbnails",
|
||||||
thumbnail_regenerate: "Regenerate",
|
thumbnail_regenerate: "Regen. Thumbnails",
|
||||||
|
cbr_to_cbz: "CBR → CBZ",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface JobTypeBadgeProps {
|
interface JobTypeBadgeProps {
|
||||||
|
|||||||
@@ -248,6 +248,29 @@ body::after {
|
|||||||
overflow: hidden;
|
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 */
|
/* Hide scrollbar */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ interface JobDetailPageProps {
|
|||||||
interface JobDetails {
|
interface JobDetails {
|
||||||
id: string;
|
id: string;
|
||||||
library_id: string | null;
|
library_id: string | null;
|
||||||
|
book_id: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
|
phase2_started_at: string | null;
|
||||||
current_file: string | null;
|
current_file: string | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
@@ -38,6 +40,34 @@ interface JobError {
|
|||||||
created_at: string;
|
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> {
|
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<JobDetails>(`/index/jobs/${jobId}`);
|
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`;
|
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSpeed(stats: { scanned_files: number } | null, duration: number): string {
|
function formatSpeed(count: number, durationMs: number): string {
|
||||||
if (!stats || duration === 0) return "-";
|
if (durationMs === 0 || count === 0) return "-";
|
||||||
const filesPerSecond = stats.scanned_files / (duration / 1000);
|
return `${(count / (durationMs / 1000)).toFixed(1)}/s`;
|
||||||
return `${filesPerSecond.toFixed(1)} f/s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||||
@@ -81,10 +110,44 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
notFound();
|
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()
|
? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime()
|
||||||
: 0;
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<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>
|
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>Overview</CardTitle>
|
||||||
|
{typeInfo.description && (
|
||||||
|
<CardDescription>{typeInfo.description}</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<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>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted-foreground">Type</span>
|
<span className="text-sm text-muted-foreground">Type</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<JobTypeBadge type={job.type} />
|
<JobTypeBadge type={job.type} />
|
||||||
|
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted-foreground">Status</span>
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</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-muted-foreground">Library</span>
|
||||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -131,101 +277,194 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Timeline</CardTitle>
|
<CardTitle>Timeline</CardTitle>
|
||||||
</CardHeader>
|
</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="flex items-start gap-4">
|
||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Created</span>
|
<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>
|
||||||
</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="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="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Started</span>
|
<span className="text-sm font-medium text-foreground">Phase 1 — Discovery</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||||
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Progress Card */}
|
{/* Progress Card */}
|
||||||
{(job.status === "running" || job.status === "generating_thumbnails" || job.status === "success" || job.status === "failed") && (
|
{showProgressCard && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{job.status === "generating_thumbnails" ? "Thumbnails" : "Progress"}</CardTitle>
|
<CardTitle>{progressTitle}</CardTitle>
|
||||||
|
{progressDescription && <CardDescription>{progressDescription}</CardDescription>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{job.total_files != null && job.total_files > 0 && (
|
{job.total_files != null && job.total_files > 0 && (
|
||||||
<>
|
<>
|
||||||
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<StatBox value={job.processed_files ?? 0} label="Processed" variant="primary" />
|
<StatBox
|
||||||
<StatBox value={job.total_files} label={job.status === "generating_thumbnails" ? "Total thumbnails" : "Total"} />
|
value={job.processed_files ?? 0}
|
||||||
<StatBox value={job.total_files - (job.processed_files ?? 0)} label="Remaining" variant="warning" />
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
<span className="text-sm text-muted-foreground">Current file:</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">Current file</span>
|
||||||
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Card */}
|
{/* Index Statistics — index jobs only */}
|
||||||
{job.stats_json && (
|
{job.stats_json && !isThumbnailOnly && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<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.scanned_files} label="Scanned" variant="success" />
|
||||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
<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.removed_files} label="Removed" variant="warning" />
|
||||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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 && (
|
{errors.length > 0 && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Errors ({errors.length})</CardTitle>
|
<CardTitle>File errors ({errors.length})</CardTitle>
|
||||||
<CardDescription>Errors encountered during job execution</CardDescription>
|
<CardDescription>Errors encountered while processing individual files</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
@@ -238,19 +477,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export type FolderItem = {
|
|||||||
has_children: boolean;
|
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 = {
|
export type BookDto = {
|
||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
@@ -60,6 +68,10 @@ export type BookDto = {
|
|||||||
file_format: string | null;
|
file_format: string | null;
|
||||||
file_parse_status: string | null;
|
file_parse_status: string | null;
|
||||||
updated_at: string;
|
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 = {
|
export type BooksPageDto = {
|
||||||
@@ -353,3 +365,18 @@ export async function getThumbnailStats() {
|
|||||||
export async function convertBook(bookId: string) {
|
export async function convertBook(bookId: string) {
|
||||||
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -388,16 +388,14 @@ pub async fn regenerate_thumbnails(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete orphaned thumbnail files (books deleted in full_rebuild get new UUIDs).
|
/// Delete orphaned thumbnail files (books deleted in full_rebuild get new UUIDs).
|
||||||
pub async fn cleanup_orphaned_thumbnails(
|
pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> Result<()> {
|
||||||
state: &AppState,
|
|
||||||
library_id: Option<Uuid>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let config = load_thumbnail_config(&state.pool).await;
|
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(
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ pub async fn process_job(
|
|||||||
// Thumbnail rebuild: generate thumbnails for books missing them
|
// Thumbnail rebuild: generate thumbnails for books missing them
|
||||||
if job_type == "thumbnail_rebuild" {
|
if job_type == "thumbnail_rebuild" {
|
||||||
sqlx::query(
|
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)
|
.bind(job_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -178,7 +178,7 @@ pub async fn process_job(
|
|||||||
// Thumbnail regenerate: clear all thumbnails then re-generate
|
// Thumbnail regenerate: clear all thumbnails then re-generate
|
||||||
if job_type == "thumbnail_regenerate" {
|
if job_type == "thumbnail_regenerate" {
|
||||||
sqlx::query(
|
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)
|
.bind(job_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -315,12 +315,12 @@ pub async fn process_job(
|
|||||||
|
|
||||||
// For full rebuild: clean up orphaned thumbnail files (old UUIDs)
|
// For full rebuild: clean up orphaned thumbnail files (old UUIDs)
|
||||||
if is_full_rebuild {
|
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)
|
// Phase 2: Analysis (extract page_count + thumbnails for new/updated books)
|
||||||
sqlx::query(
|
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(job_id)
|
||||||
.bind(serde_json::to_value(&stats)?)
|
.bind(serde_json::to_value(&stats)?)
|
||||||
|
|||||||
2
infra/migrations/0015_index_job_phase2_started_at.sql
Normal file
2
infra/migrations/0015_index_job_phase2_started_at.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE index_jobs
|
||||||
|
ADD COLUMN phase2_started_at TIMESTAMPTZ;
|
||||||
9
infra/migrations/0016_add_reading_progress.sql
Normal file
9
infra/migrations/0016_add_reading_progress.sql
Normal 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);
|
||||||
@@ -122,6 +122,62 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ─────────────────────────────────────────────────────────────────
|
# ─── metrics ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "[smoke] metrics"
|
echo "[smoke] metrics"
|
||||||
|
|||||||
2
openspec/changes/reading-progress/.openspec.yaml
Normal file
2
openspec/changes/reading-progress/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-10
|
||||||
66
openspec/changes/reading-progress/design.md
Normal file
66
openspec/changes/reading-progress/design.md
Normal 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)
|
||||||
29
openspec/changes/reading-progress/proposal.md
Normal file
29
openspec/changes/reading-progress/proposal.md
Normal 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
|
||||||
16
openspec/changes/reading-progress/specs/book-details/spec.md
Normal file
16
openspec/changes/reading-progress/specs/book-details/spec.md
Normal 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
|
||||||
@@ -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())
|
||||||
28
openspec/changes/reading-progress/tasks.md
Normal file
28
openspec/changes/reading-progress/tasks.md
Normal 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é
|
||||||
Reference in New Issue
Block a user