feat(jobs): introduce extracting_pages status and update job progress handling
- Added a new job status 'extracting_pages' to represent the first sub-phase of thumbnail generation. - Updated the database schema to include a timestamp for when thumbnail generation starts. - Enhanced job progress components to handle the new status, including UI updates for displaying progress and status labels. - Refactored job-related logic to accommodate the two-phase process: extracting pages and generating thumbnails. - Adjusted SQL queries and job detail responses to include the new fields and statuses. This change improves the clarity of job processing states and enhances user feedback during the thumbnail generation process.
This commit is contained in:
@@ -65,6 +65,8 @@ pub struct IndexJobDetailResponse {
|
|||||||
pub finished_at: Option<DateTime<Utc>>,
|
pub finished_at: Option<DateTime<Utc>>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub phase2_started_at: Option<DateTime<Utc>>,
|
pub phase2_started_at: Option<DateTime<Utc>>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub generating_thumbnails_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)]
|
||||||
@@ -324,6 +326,7 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse {
|
|||||||
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(),
|
phase2_started_at: row.try_get("phase2_started_at").ok().flatten(),
|
||||||
|
generating_thumbnails_started_at: row.try_get("generating_thumbnails_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"),
|
||||||
@@ -350,7 +353,7 @@ pub async fn get_active_jobs(State(state): State<AppState>) -> Result<Json<Vec<I
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files
|
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files
|
||||||
FROM index_jobs
|
FROM index_jobs
|
||||||
WHERE status IN ('pending', 'running', 'generating_thumbnails')
|
WHERE status IN ('pending', 'running', 'extracting_pages', 'generating_thumbnails')
|
||||||
ORDER BY created_at ASC"
|
ORDER BY created_at ASC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -380,7 +383,7 @@ 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, book_id, type, status, started_at, finished_at, phase2_started_at,
|
"SELECT id, library_id, book_id, type, status, started_at, finished_at, phase2_started_at, generating_thumbnails_started_at,
|
||||||
stats_json, error_opt, created_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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const percent = progress.progress_percent ?? 0;
|
const percent = progress.progress_percent ?? 0;
|
||||||
const processed = progress.processed_files ?? 0;
|
const processed = progress.processed_files ?? 0;
|
||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
const isThumbnailsPhase = progress.status === "generating_thumbnails";
|
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
||||||
const unitLabel = isThumbnailsPhase ? "thumbnails" : "files";
|
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "thumbnails" : "files";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-card rounded-lg border border-border">
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
@@ -112,7 +112,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress.stats_json && !isThumbnailsPhase && (
|
{progress.stats_json && !isPhase2 && (
|
||||||
<div className="flex flex-wrap gap-3 text-xs">
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface JobRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||||
const isActive = job.status === "running" || job.status === "pending" || job.status === "generating_thumbnails";
|
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||||
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
@@ -52,13 +52,14 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
const removed = job.stats_json?.removed_files ?? 0;
|
const removed = job.stats_json?.removed_files ?? 0;
|
||||||
const errors = job.stats_json?.errors ?? 0;
|
const errors = job.stats_json?.errors ?? 0;
|
||||||
|
|
||||||
|
const isPhase2 = job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||||
const isThumbnailPhase = job.status === "generating_thumbnails";
|
const isThumbnailPhase = job.status === "generating_thumbnails";
|
||||||
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
||||||
const hasThumbnailPhase = isThumbnailPhase || isThumbnailJob;
|
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
|
||||||
|
|
||||||
// Files column: index-phase stats only
|
// Files column: index-phase stats only (Phase 1 discovery)
|
||||||
const filesDisplay =
|
const filesDisplay =
|
||||||
job.status === "running" && !isThumbnailPhase
|
job.status === "running" && !isPhase2
|
||||||
? job.total_files != null
|
? job.total_files != null
|
||||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||||
: scanned > 0
|
: scanned > 0
|
||||||
@@ -70,8 +71,8 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
? `${scanned} scanned`
|
? `${scanned} scanned`
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
// Thumbnails column
|
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
|
||||||
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isThumbnailPhase);
|
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
|
||||||
const thumbDisplay =
|
const thumbDisplay =
|
||||||
thumbInProgress && job.total_files != null
|
thumbInProgress && job.total_files != null
|
||||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||||
@@ -128,7 +129,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
{errors > 0 && <span className="text-error">⚠ {errors}</span>}
|
{errors > 0 && <span className="text-error">⚠ {errors}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{job.status === "running" && !isThumbnailPhase && job.total_files != null && (
|
{job.status === "running" && !isPhase2 && job.total_files != null && (
|
||||||
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +156,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running" || job.status === "generating_thumbnails") && (
|
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function JobsIndicator() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "generating_thumbnails");
|
const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "extracting_pages" || j.status === "generating_thumbnails");
|
||||||
const pendingJobs = activeJobs.filter(j => j.status === "pending");
|
const pendingJobs = activeJobs.filter(j => j.status === "pending");
|
||||||
const totalCount = activeJobs.length;
|
const totalCount = activeJobs.length;
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ export function JobsIndicator() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{(job.status === "running" || job.status === "generating_thumbnails") && <span className="animate-spin inline-block">⏳</span>}
|
{(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && <span className="animate-spin inline-block">⏳</span>}
|
||||||
{job.status === "pending" && <span>⏸</span>}
|
{job.status === "pending" && <span>⏸</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ export function JobsIndicator() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(job.status === "running" || job.status === "generating_thumbnails") && job.progress_percent != null && (
|
{(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && job.progress_percent != null && (
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<MiniProgressBar value={job.progress_percent} />
|
<MiniProgressBar value={job.progress_percent} />
|
||||||
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
|
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function Badge({ children, variant = "default", className = "" }: BadgePr
|
|||||||
// Status badge for jobs/tasks
|
// Status badge for jobs/tasks
|
||||||
const statusVariants: Record<string, BadgeVariant> = {
|
const statusVariants: Record<string, BadgeVariant> = {
|
||||||
running: "in-progress",
|
running: "in-progress",
|
||||||
|
extracting_pages: "in-progress",
|
||||||
generating_thumbnails: "in-progress",
|
generating_thumbnails: "in-progress",
|
||||||
success: "completed",
|
success: "completed",
|
||||||
completed: "completed",
|
completed: "completed",
|
||||||
@@ -70,6 +71,7 @@ const statusVariants: Record<string, BadgeVariant> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
|
extracting_pages: "Extracting pages",
|
||||||
generating_thumbnails: "Thumbnails",
|
generating_thumbnails: "Thumbnails",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface JobDetails {
|
|||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
phase2_started_at: string | null;
|
phase2_started_at: string | null;
|
||||||
|
generating_thumbnails_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;
|
||||||
@@ -123,18 +124,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
const isCompleted = job.status === "success";
|
const isCompleted = job.status === "success";
|
||||||
const isFailed = job.status === "failed";
|
const isFailed = job.status === "failed";
|
||||||
const isCancelled = job.status === "cancelled";
|
const isCancelled = job.status === "cancelled";
|
||||||
|
const isExtractingPages = job.status === "extracting_pages";
|
||||||
const isThumbnailPhase = job.status === "generating_thumbnails";
|
const isThumbnailPhase = job.status === "generating_thumbnails";
|
||||||
|
const isPhase2 = isExtractingPages || isThumbnailPhase;
|
||||||
const { isThumbnailOnly } = typeInfo;
|
const { isThumbnailOnly } = typeInfo;
|
||||||
|
|
||||||
// Which label to use for the progress card
|
// Which label to use for the progress card
|
||||||
const progressTitle = isThumbnailOnly
|
const progressTitle = isThumbnailOnly
|
||||||
? "Thumbnails"
|
? "Thumbnails"
|
||||||
|
: isExtractingPages
|
||||||
|
? "Phase 2 — Extracting pages"
|
||||||
: isThumbnailPhase
|
: isThumbnailPhase
|
||||||
? "Phase 2 — Thumbnails"
|
? "Phase 2 — Thumbnails"
|
||||||
: "Phase 1 — Discovery";
|
: "Phase 1 — Discovery";
|
||||||
|
|
||||||
const progressDescription = isThumbnailOnly
|
const progressDescription = isThumbnailOnly
|
||||||
? undefined
|
? undefined
|
||||||
|
: isExtractingPages
|
||||||
|
? "Extracting first page from each archive (page count + raw image)"
|
||||||
: isThumbnailPhase
|
: isThumbnailPhase
|
||||||
? "Generating thumbnails for the analyzed books"
|
? "Generating thumbnails for the analyzed books"
|
||||||
: "Scanning and indexing files in the library";
|
: "Scanning and indexing files in the library";
|
||||||
@@ -145,7 +152,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
: (job.stats_json?.scanned_files ?? 0);
|
: (job.stats_json?.scanned_files ?? 0);
|
||||||
|
|
||||||
const showProgressCard =
|
const showProgressCard =
|
||||||
(isCompleted || isFailed || job.status === "running" || isThumbnailPhase) &&
|
(isCompleted || isFailed || job.status === "running" || isPhase2) &&
|
||||||
(job.total_files != null || !!job.current_file);
|
(job.total_files != null || !!job.current_file);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -312,20 +319,44 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase 2 start — for index jobs that have two phases */}
|
{/* Phase 2a — Extracting pages (index jobs with phase2) */}
|
||||||
{job.phase2_started_at && (
|
{job.phase2_started_at && !isThumbnailOnly && (
|
||||||
|
<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.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
|
}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-foreground">Phase 2a — Extracting pages</span>
|
||||||
|
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||||
|
Duration: {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||||
|
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||||
|
<span className="text-muted-foreground font-normal ml-1">· in progress</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phase 2b — Generating thumbnails */}
|
||||||
|
{(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
|
<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"
|
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{isThumbnailOnly ? "Thumbnails" : "Phase 2 — Thumbnails"}
|
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
{job.finished_at && (
|
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||||
Duration: {formatDuration(job.phase2_started_at, job.finished_at)}
|
Duration: {formatDuration(
|
||||||
|
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||||
|
job.finished_at ?? null
|
||||||
|
)}
|
||||||
{job.total_files != null && job.total_files > 0 && (
|
{job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
· {job.processed_files ?? job.total_files} thumbnails
|
· {job.processed_files ?? job.total_files} thumbnails
|
||||||
@@ -333,6 +364,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!job.finished_at && isThumbnailPhase && (
|
||||||
|
<span className="text-xs text-muted-foreground">in progress</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -393,7 +427,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<StatBox
|
<StatBox
|
||||||
value={job.processed_files ?? 0}
|
value={job.processed_files ?? 0}
|
||||||
label={isThumbnailOnly || isThumbnailPhase ? "Generated" : "Processed"}
|
label={isThumbnailOnly || isPhase2 ? "Generated" : "Processed"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<StatBox value={job.total_files} label="Total" />
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -103,17 +103,32 @@ fn generate_thumbnail(image_bytes: &[u8], config: &ThumbnailConfig) -> anyhow::R
|
|||||||
Ok(webp_data.to_vec())
|
Ok(webp_data.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_thumbnail(
|
/// Save raw image bytes (as extracted from the archive) without any processing.
|
||||||
|
fn save_raw_image(book_id: Uuid, raw_bytes: &[u8], directory: &str) -> anyhow::Result<String> {
|
||||||
|
let dir = Path::new(directory);
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let path = dir.join(format!("{}.raw", book_id));
|
||||||
|
std::fs::write(&path, raw_bytes)?;
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the raw image and save it as a WebP thumbnail, overwriting the raw file.
|
||||||
|
fn resize_raw_to_webp(
|
||||||
book_id: Uuid,
|
book_id: Uuid,
|
||||||
thumbnail_bytes: &[u8],
|
raw_path: &str,
|
||||||
config: &ThumbnailConfig,
|
config: &ThumbnailConfig,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let dir = Path::new(&config.directory);
|
let raw_bytes = std::fs::read(raw_path)
|
||||||
std::fs::create_dir_all(dir)?;
|
.map_err(|e| anyhow::anyhow!("failed to read raw image {}: {}", raw_path, e))?;
|
||||||
let filename = format!("{}.webp", book_id);
|
let webp_bytes = generate_thumbnail(&raw_bytes, config)?;
|
||||||
let path = dir.join(&filename);
|
|
||||||
std::fs::write(&path, thumbnail_bytes)?;
|
let webp_path = Path::new(&config.directory).join(format!("{}.webp", book_id));
|
||||||
Ok(path.to_string_lossy().to_string())
|
std::fs::write(&webp_path, &webp_bytes)?;
|
||||||
|
|
||||||
|
// Delete the raw file now that the WebP is written
|
||||||
|
let _ = std::fs::remove_file(raw_path);
|
||||||
|
|
||||||
|
Ok(webp_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn book_format_from_str(s: &str) -> Option<BookFormat> {
|
fn book_format_from_str(s: &str) -> Option<BookFormat> {
|
||||||
@@ -125,7 +140,14 @@ fn book_format_from_str(s: &str) -> Option<BookFormat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2 — Analysis: open each unanalyzed archive once, extract page_count + thumbnail.
|
/// Phase 2 — Two-sub-phase analysis:
|
||||||
|
///
|
||||||
|
/// **Sub-phase A (extracting_pages)**: open each archive once, extract (page_count, raw_image_bytes),
|
||||||
|
/// save the raw bytes to `{directory}/{book_id}.raw`. I/O bound — runs at `concurrent_renders`.
|
||||||
|
///
|
||||||
|
/// **Sub-phase B (generating_thumbnails)**: load each `.raw` file, resize and encode as WebP,
|
||||||
|
/// overwrite as `{directory}/{book_id}.webp`. CPU bound — runs at `concurrent_renders`.
|
||||||
|
///
|
||||||
/// `thumbnail_only` = true: only process books missing thumbnail (page_count may already be set).
|
/// `thumbnail_only` = true: only process books missing thumbnail (page_count may already be set).
|
||||||
/// `thumbnail_only` = false: process books missing page_count.
|
/// `thumbnail_only` = false: process books missing page_count.
|
||||||
pub async fn analyze_library_books(
|
pub async fn analyze_library_books(
|
||||||
@@ -143,7 +165,6 @@ pub async fn analyze_library_books(
|
|||||||
|
|
||||||
let concurrency = load_thumbnail_concurrency(&state.pool).await;
|
let concurrency = load_thumbnail_concurrency(&state.pool).await;
|
||||||
|
|
||||||
// Query books that need analysis
|
|
||||||
let query_filter = if thumbnail_only {
|
let query_filter = if thumbnail_only {
|
||||||
"b.thumbnail_path IS NULL"
|
"b.thumbnail_path IS NULL"
|
||||||
} else {
|
} else {
|
||||||
@@ -177,19 +198,7 @@ pub async fn analyze_library_books(
|
|||||||
total, thumbnail_only, concurrency
|
total, thumbnail_only, concurrency
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update job status
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE index_jobs SET status = 'generating_thumbnails', total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(job_id)
|
|
||||||
.bind(total)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let processed_count = Arc::new(AtomicI32::new(0));
|
|
||||||
let cancelled_flag = Arc::new(AtomicBool::new(false));
|
let cancelled_flag = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
// Background task: poll DB every 2s to detect cancellation
|
|
||||||
let cancel_pool = state.pool.clone();
|
let cancel_pool = state.pool.clone();
|
||||||
let cancel_flag_for_poller = cancelled_flag.clone();
|
let cancel_flag_for_poller = cancelled_flag.clone();
|
||||||
let cancel_handle = tokio::spawn(async move {
|
let cancel_handle = tokio::spawn(async move {
|
||||||
@@ -221,43 +230,56 @@ pub async fn analyze_library_books(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
stream::iter(tasks)
|
// -------------------------------------------------------------------------
|
||||||
.for_each_concurrent(concurrency, |task| {
|
// Sub-phase A: extract first page from each archive and store raw image
|
||||||
let processed_count = processed_count.clone();
|
// I/O bound — limited by HDD throughput, runs at `concurrency`
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
let phase_a_start = std::time::Instant::now();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'extracting_pages', total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(total)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let extracted_count = Arc::new(AtomicI32::new(0));
|
||||||
|
|
||||||
|
// Collected results: (book_id, raw_path, page_count)
|
||||||
|
let extracted: Vec<(Uuid, String, i32)> = stream::iter(tasks)
|
||||||
|
.map(|task| {
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let cancelled = cancelled_flag.clone();
|
let cancelled = cancelled_flag.clone();
|
||||||
|
let extracted_count = extracted_count.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let local_path = utils::remap_libraries_path(&task.abs_path);
|
let local_path = utils::remap_libraries_path(&task.abs_path);
|
||||||
let path = Path::new(&local_path);
|
let path = std::path::Path::new(&local_path);
|
||||||
|
let book_id = task.book_id;
|
||||||
|
|
||||||
let format = match book_format_from_str(&task.format) {
|
let format = match book_format_from_str(&task.format) {
|
||||||
Some(f) => f,
|
Some(f) => f,
|
||||||
None => {
|
None => {
|
||||||
warn!("[ANALYZER] Unknown format '{}' for book {}", task.format, task.book_id);
|
warn!("[ANALYZER] Unknown format '{}' for book {}", task.format, book_id);
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run blocking archive I/O on a thread pool
|
|
||||||
let book_id = task.book_id;
|
|
||||||
let path_owned = path.to_path_buf();
|
|
||||||
let pdf_scale = config.width.max(config.height);
|
let pdf_scale = config.width.max(config.height);
|
||||||
let analyze_result = tokio::task::spawn_blocking(move || {
|
let path_owned = path.to_path_buf();
|
||||||
analyze_book(&path_owned, format, pdf_scale)
|
let analyze_result =
|
||||||
})
|
tokio::task::spawn_blocking(move || analyze_book(&path_owned, format, pdf_scale))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (page_count, image_bytes) = match analyze_result {
|
let (page_count, raw_bytes) = match analyze_result {
|
||||||
Ok(Ok(result)) => result,
|
Ok(Ok(result)) => result,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
warn!("[ANALYZER] analyze_book failed for book {}: {}", book_id, e);
|
warn!("[ANALYZER] analyze_book failed for book {}: {}", book_id, e);
|
||||||
// Mark parse_status = error in book_files
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE book_files SET parse_status = 'error', parse_error_opt = $2 WHERE book_id = $1",
|
"UPDATE book_files SET parse_status = 'error', parse_error_opt = $2 WHERE book_id = $1",
|
||||||
)
|
)
|
||||||
@@ -265,66 +287,125 @@ pub async fn analyze_library_books(
|
|||||||
.bind(e.to_string())
|
.bind(e.to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("[ANALYZER] spawn_blocking error for book {}: {}", book_id, e);
|
warn!("[ANALYZER] spawn_blocking error for book {}: {}", book_id, e);
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate thumbnail
|
// Save raw bytes to disk (no resize, no encode)
|
||||||
let thumb_result = tokio::task::spawn_blocking({
|
let raw_path = match tokio::task::spawn_blocking({
|
||||||
|
let dir = config.directory.clone();
|
||||||
|
let bytes = raw_bytes.clone();
|
||||||
|
move || save_raw_image(book_id, &bytes, &dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(p)) => p,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
warn!("[ANALYZER] save_raw_image failed for book {}: {}", book_id, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[ANALYZER] spawn_blocking save_raw error for book {}: {}", book_id, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update page_count in DB
|
||||||
|
if let Err(e) = sqlx::query("UPDATE books SET page_count = $1 WHERE id = $2")
|
||||||
|
.bind(page_count)
|
||||||
|
.bind(book_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("[ANALYZER] DB page_count update failed for book {}: {}", book_id, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = extracted_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
let percent = (processed as f64 / total as f64 * 50.0) as i32; // first 50%
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(processed)
|
||||||
|
.bind(percent)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Some((book_id, raw_path, page_count))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffer_unordered(concurrency)
|
||||||
|
.filter_map(|x| async move { x })
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if cancelled_flag.load(Ordering::Relaxed) {
|
||||||
|
cancel_handle.abort();
|
||||||
|
info!("[ANALYZER] Job {} cancelled during extraction phase", job_id);
|
||||||
|
return Err(anyhow::anyhow!("Job cancelled by user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let extracted_total = extracted.len() as i32;
|
||||||
|
let phase_a_elapsed = phase_a_start.elapsed();
|
||||||
|
info!(
|
||||||
|
"[ANALYZER] Sub-phase A complete: {}/{} books extracted in {:.1}s ({:.0} ms/book)",
|
||||||
|
extracted_total,
|
||||||
|
total,
|
||||||
|
phase_a_elapsed.as_secs_f64(),
|
||||||
|
if extracted_total > 0 { phase_a_elapsed.as_millis() as f64 / extracted_total as f64 } else { 0.0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Sub-phase B: resize raw images and encode as WebP
|
||||||
|
// CPU bound — can run at higher concurrency than I/O phase
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
let phase_b_start = std::time::Instant::now();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'generating_thumbnails', generating_thumbnails_started_at = NOW(), total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(extracted_total)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let resize_count = Arc::new(AtomicI32::new(0));
|
||||||
|
|
||||||
|
stream::iter(extracted)
|
||||||
|
.for_each_concurrent(concurrency, |(book_id, raw_path, page_count)| {
|
||||||
|
let pool = state.pool.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
move || generate_thumbnail(&image_bytes, &config)
|
let cancelled = cancelled_flag.clone();
|
||||||
|
let resize_count = resize_count.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_path_clone = raw_path.clone();
|
||||||
|
let thumb_result = tokio::task::spawn_blocking(move || {
|
||||||
|
resize_raw_to_webp(book_id, &raw_path_clone, &config)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let thumb_bytes = match thumb_result {
|
let thumb_path = match thumb_result {
|
||||||
Ok(Ok(b)) => b,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
warn!("[ANALYZER] thumbnail generation failed for book {}: {}", book_id, e);
|
|
||||||
// Still update page_count even if thumbnail fails
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE books SET page_count = $1 WHERE id = $2",
|
|
||||||
)
|
|
||||||
.bind(page_count)
|
|
||||||
.bind(book_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("[ANALYZER] spawn_blocking thumbnail error for book {}: {}", book_id, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save thumbnail file
|
|
||||||
let save_result = {
|
|
||||||
let config = config.clone();
|
|
||||||
tokio::task::spawn_blocking(move || save_thumbnail(book_id, &thumb_bytes, &config))
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
|
|
||||||
let thumb_path = match save_result {
|
|
||||||
Ok(Ok(p)) => p,
|
Ok(Ok(p)) => p,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
warn!("[ANALYZER] save_thumbnail failed for book {}: {}", book_id, e);
|
warn!("[ANALYZER] resize_raw_to_webp failed for book {}: {}", book_id, e);
|
||||||
let _ = sqlx::query("UPDATE books SET page_count = $1 WHERE id = $2")
|
// page_count is already set; thumbnail stays NULL
|
||||||
.bind(page_count)
|
|
||||||
.bind(book_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("[ANALYZER] spawn_blocking save error for book {}: {}", book_id, e);
|
warn!("[ANALYZER] spawn_blocking resize error for book {}: {}", book_id, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update DB
|
|
||||||
if let Err(e) = sqlx::query(
|
if let Err(e) = sqlx::query(
|
||||||
"UPDATE books SET page_count = $1, thumbnail_path = $2 WHERE id = $3",
|
"UPDATE books SET page_count = $1, thumbnail_path = $2 WHERE id = $3",
|
||||||
)
|
)
|
||||||
@@ -334,12 +415,13 @@ pub async fn analyze_library_books(
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("[ANALYZER] DB update failed for book {}: {}", book_id, e);
|
warn!("[ANALYZER] DB thumbnail update failed for book {}: {}", book_id, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let processed = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
|
let processed = resize_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
let percent = (processed as f64 / total as f64 * 100.0) as i32;
|
let percent =
|
||||||
|
50 + (processed as f64 / extracted_total as f64 * 50.0) as i32; // last 50%
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
|
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -355,14 +437,24 @@ pub async fn analyze_library_books(
|
|||||||
cancel_handle.abort();
|
cancel_handle.abort();
|
||||||
|
|
||||||
if cancelled_flag.load(Ordering::Relaxed) {
|
if cancelled_flag.load(Ordering::Relaxed) {
|
||||||
info!("[ANALYZER] Job {} cancelled by user, stopping analysis", job_id);
|
info!("[ANALYZER] Job {} cancelled during resize phase", job_id);
|
||||||
return Err(anyhow::anyhow!("Job cancelled by user"));
|
return Err(anyhow::anyhow!("Job cancelled by user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_count = processed_count.load(Ordering::Relaxed);
|
let final_count = resize_count.load(Ordering::Relaxed);
|
||||||
|
let phase_b_elapsed = phase_b_start.elapsed();
|
||||||
info!(
|
info!(
|
||||||
"[ANALYZER] Analysis complete: {}/{} books processed",
|
"[ANALYZER] Sub-phase B complete: {}/{} thumbnails generated in {:.1}s ({:.0} ms/book)",
|
||||||
final_count, total
|
final_count,
|
||||||
|
extracted_total,
|
||||||
|
phase_b_elapsed.as_secs_f64(),
|
||||||
|
if final_count > 0 { phase_b_elapsed.as_millis() as f64 / final_count as f64 } else { 0.0 }
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"[ANALYZER] Total: {:.1}s (extraction {:.1}s + resize {:.1}s)",
|
||||||
|
(phase_a_elapsed + phase_b_elapsed).as_secs_f64(),
|
||||||
|
phase_a_elapsed.as_secs_f64(),
|
||||||
|
phase_b_elapsed.as_secs_f64(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -376,7 +468,6 @@ pub async fn regenerate_thumbnails(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config = load_thumbnail_config(&state.pool).await;
|
let config = load_thumbnail_config(&state.pool).await;
|
||||||
|
|
||||||
// Delete thumbnail files for all books in scope
|
|
||||||
let book_ids_to_clear: Vec<Uuid> = sqlx::query_scalar(
|
let book_ids_to_clear: Vec<Uuid> = sqlx::query_scalar(
|
||||||
r#"SELECT id FROM books WHERE (library_id = $1 OR $1 IS NULL) AND thumbnail_path IS NOT NULL"#,
|
r#"SELECT id FROM books WHERE (library_id = $1 OR $1 IS NULL) AND thumbnail_path IS NOT NULL"#,
|
||||||
)
|
)
|
||||||
@@ -387,34 +478,26 @@ pub async fn regenerate_thumbnails(
|
|||||||
|
|
||||||
let mut deleted_count = 0usize;
|
let mut deleted_count = 0usize;
|
||||||
for book_id in &book_ids_to_clear {
|
for book_id in &book_ids_to_clear {
|
||||||
let filename = format!("{}.webp", book_id);
|
// Delete WebP thumbnail
|
||||||
let thumbnail_path = Path::new(&config.directory).join(&filename);
|
let webp_path = Path::new(&config.directory).join(format!("{}.webp", book_id));
|
||||||
if thumbnail_path.exists() {
|
if webp_path.exists() {
|
||||||
if let Err(e) = std::fs::remove_file(&thumbnail_path) {
|
if let Err(e) = std::fs::remove_file(&webp_path) {
|
||||||
warn!(
|
warn!("[ANALYZER] Failed to delete thumbnail {}: {}", webp_path.display(), e);
|
||||||
"[ANALYZER] Failed to delete thumbnail {}: {}",
|
|
||||||
thumbnail_path.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
deleted_count += 1;
|
deleted_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Delete raw file if it exists (interrupted previous run)
|
||||||
|
let raw_path = Path::new(&config.directory).join(format!("{}.raw", book_id));
|
||||||
|
let _ = std::fs::remove_file(&raw_path);
|
||||||
}
|
}
|
||||||
info!(
|
info!("[ANALYZER] Deleted {} thumbnail files for regeneration", deleted_count);
|
||||||
"[ANALYZER] Deleted {} thumbnail files for regeneration",
|
|
||||||
deleted_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear thumbnail_path in DB
|
sqlx::query(r#"UPDATE books SET thumbnail_path = NULL WHERE (library_id = $1 OR $1 IS NULL)"#)
|
||||||
sqlx::query(
|
|
||||||
r#"UPDATE books SET thumbnail_path = NULL WHERE (library_id = $1 OR $1 IS NULL)"#,
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Re-analyze all books (now thumbnail_path IS NULL for all)
|
|
||||||
analyze_library_books(state, job_id, library_id, true).await
|
analyze_library_books(state, job_id, library_id, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,11 +505,8 @@ pub async fn regenerate_thumbnails(
|
|||||||
pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> Result<()> {
|
pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> 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
|
let existing_book_ids: std::collections::HashSet<Uuid> =
|
||||||
// deleting thumbnails that belong to other libraries during a per-library rebuild.
|
sqlx::query_scalar(r#"SELECT id FROM books"#)
|
||||||
let existing_book_ids: std::collections::HashSet<Uuid> = sqlx::query_scalar(
|
|
||||||
r#"SELECT id FROM books"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -441,17 +521,21 @@ pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> Result<()> {
|
|||||||
let mut deleted_count = 0usize;
|
let mut deleted_count = 0usize;
|
||||||
if let Ok(entries) = std::fs::read_dir(thumbnail_dir) {
|
if let Ok(entries) = std::fs::read_dir(thumbnail_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if let Some(file_name) = entry.file_name().to_str() {
|
let file_name = entry.file_name();
|
||||||
if file_name.ends_with(".webp") {
|
let file_name = file_name.to_string_lossy();
|
||||||
if let Some(book_id_str) = file_name.strip_suffix(".webp") {
|
// Clean up both .webp and orphaned .raw files
|
||||||
if let Ok(book_id) = Uuid::parse_str(book_id_str) {
|
let stem = if let Some(s) = file_name.strip_suffix(".webp") {
|
||||||
|
Some(s.to_string())
|
||||||
|
} else if let Some(s) = file_name.strip_suffix(".raw") {
|
||||||
|
Some(s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(book_id_str) = stem {
|
||||||
|
if let Ok(book_id) = Uuid::parse_str(&book_id_str) {
|
||||||
if !existing_book_ids.contains(&book_id) {
|
if !existing_book_ids.contains(&book_id) {
|
||||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||||
warn!(
|
warn!("Failed to delete orphaned file {}: {}", entry.path().display(), e);
|
||||||
"Failed to delete orphaned thumbnail {}: {}",
|
|
||||||
entry.path().display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
deleted_count += 1;
|
deleted_count += 1;
|
||||||
}
|
}
|
||||||
@@ -460,12 +544,7 @@ pub async fn cleanup_orphaned_thumbnails(state: &AppState) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
info!("[ANALYZER] Deleted {} orphaned thumbnail files", deleted_count);
|
||||||
"[ANALYZER] Deleted {} orphaned thumbnail files",
|
|
||||||
deleted_count
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
7
infra/migrations/0018_add_extracting_pages_status.sql
Normal file
7
infra/migrations/0018_add_extracting_pages_status.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration: Add status 'extracting_pages' for the first sub-phase of thumbnail generation
|
||||||
|
-- Phase 1 (extracting_pages): extract raw first-page image from archive, store as-is
|
||||||
|
-- Phase 2 (generating_thumbnails): resize and encode as WebP
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS index_jobs_status_check,
|
||||||
|
ADD CONSTRAINT index_jobs_status_check
|
||||||
|
CHECK (status IN ('pending', 'running', 'extracting_pages', 'generating_thumbnails', 'success', 'failed', 'cancelled'));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add timestamp for Phase 2b (generating_thumbnails) so we can show separate durations:
|
||||||
|
-- Phase 2a: phase2_started_at → generating_thumbnails_started_at (extracting_pages)
|
||||||
|
-- Phase 2b: generating_thumbnails_started_at → finished_at
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS generating_thumbnails_started_at TIMESTAMPTZ;
|
||||||
Reference in New Issue
Block a user