Skip to content

Commit e632317

Browse files
committed
🐛 Fix stale thumbnail caching
1 parent 7a83e5e commit e632317

27 files changed

Lines changed: 595 additions & 305 deletions

File tree

core/src/filesystem/image/thumbnail/generate.rs

Lines changed: 229 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use std::path::PathBuf;
1+
use std::path::{Path, PathBuf};
22

3+
use chrono::Utc;
34
use futures::{stream::FuturesUnordered, StreamExt};
45
use models::{
56
entity::{library, media, series},
@@ -9,8 +10,8 @@ use models::{
910
},
1011
};
1112
use sea_orm::{
12-
prelude::*, sea_query::Query, EntityTrait, Order, QueryFilter, QueryOrder,
13-
QuerySelect,
13+
prelude::*, sea_query::Query, ConnectionTrait, EntityTrait, Order, QueryFilter,
14+
QueryOrder, QuerySelect,
1415
};
1516
use tokio::{fs, sync::oneshot, task::spawn_blocking};
1617

@@ -39,7 +40,7 @@ pub enum ThumbnailGenerateError {
3940
ProcessorError(#[from] ProcessorError),
4041
#[error("Did not receive thumbnail generation result")]
4142
ResultNeverReceived,
42-
#[error("Failed to update media entity")]
43+
#[error("Failed to update thumbnail entity")]
4344
UpdateFailed,
4445
#[error("A candidate source for thumbnail generation could not be found")]
4546
NothingToGenerate,
@@ -59,7 +60,87 @@ pub struct GenerateThumbnailOptions {
5960
pub image_options: ImageProcessorOptions,
6061
pub core_config: StumpConfig,
6162
pub force_regen: bool,
62-
pub filename: Option<String>,
63+
pub is_custom: bool,
64+
pub target: ThumbnailTarget,
65+
}
66+
67+
#[derive(Debug, Clone)]
68+
pub enum ThumbnailTarget {
69+
Media,
70+
Series(String),
71+
Library(String),
72+
}
73+
74+
fn is_generated_thumbnail(path: &Path) -> bool {
75+
path.file_stem()
76+
.and_then(|stem| stem.to_str())
77+
.is_some_and(|stem| stem.ends_with(".generated"))
78+
}
79+
80+
pub async fn bump_media_thumbnail_fallbacks<C>(
81+
conn: &C,
82+
series_id: Option<&str>,
83+
) -> Result<(), DbErr>
84+
where
85+
C: ConnectionTrait,
86+
{
87+
let Some(series_id) = series_id else {
88+
return Ok(());
89+
};
90+
let updated_at = Some(DateTimeWithTimeZone::from(Utc::now()));
91+
92+
series::Entity::update_many()
93+
.filter(series::Column::Id.eq(series_id))
94+
.col_expr(series::Column::UpdatedAt, Expr::value(updated_at.clone()))
95+
.exec(conn)
96+
.await?;
97+
98+
library::Entity::update_many()
99+
.filter(
100+
library::Column::Id.in_subquery(
101+
Query::select()
102+
.column(series::Column::LibraryId)
103+
.from(series::Entity)
104+
.and_where(series::Column::Id.eq(series_id))
105+
.to_owned(),
106+
),
107+
)
108+
.col_expr(library::Column::UpdatedAt, Expr::value(updated_at))
109+
.exec(conn)
110+
.await?;
111+
112+
Ok(())
113+
}
114+
115+
pub async fn bump_series_thumbnail_fallbacks<C>(
116+
conn: &C,
117+
series_ids: &[String],
118+
) -> Result<(), DbErr>
119+
where
120+
C: ConnectionTrait,
121+
{
122+
if series_ids.is_empty() {
123+
return Ok(());
124+
}
125+
126+
library::Entity::update_many()
127+
.filter(
128+
library::Column::Id.in_subquery(
129+
Query::select()
130+
.column(series::Column::LibraryId)
131+
.from(series::Entity)
132+
.and_where(series::Column::Id.is_in(series_ids.iter().cloned()))
133+
.to_owned(),
134+
),
135+
)
136+
.col_expr(
137+
library::Column::UpdatedAt,
138+
Expr::value(Some(DateTimeWithTimeZone::from(Utc::now()))),
139+
)
140+
.exec(conn)
141+
.await?;
142+
143+
Ok(())
63144
}
64145

65146
/// A type alias for whether a thumbnail was generated or not during the generation process. This is
@@ -106,28 +187,40 @@ pub async fn generate_book_thumbnail(
106187
image_options,
107188
core_config,
108189
force_regen,
109-
filename,
190+
is_custom,
191+
target,
110192
}: GenerateThumbnailOptions,
111193
) -> Result<GenerateOutput, ThumbnailGenerateError> {
112194
let book_path = book.path.clone();
113-
let file_name = filename.unwrap_or_else(|| book.id.clone());
114-
115-
let file_path = if let Some(stored_path) = &book.thumbnail_path {
116-
PathBuf::from(stored_path.clone())
195+
let entity_id = match &target {
196+
ThumbnailTarget::Media => book.id.clone(),
197+
ThumbnailTarget::Series(id) | ThumbnailTarget::Library(id) => id.clone(),
198+
};
199+
let file_name = if is_custom {
200+
entity_id
117201
} else {
118-
core_config.get_thumbnails_dir().join(format!(
202+
format!("{entity_id}.generated")
203+
};
204+
205+
let file_path = match (&target, &book.thumbnail_path) {
206+
(ThumbnailTarget::Media, Some(stored_path)) => PathBuf::from(stored_path),
207+
_ => core_config.get_thumbnails_dir().join(format!(
119208
"{}.{}",
120209
file_name,
121210
image_options.format.extension()
122-
))
211+
)),
123212
};
213+
let preserve_existing = !force_regen
214+
|| matches!(&target, ThumbnailTarget::Media)
215+
&& !is_custom
216+
&& !is_generated_thumbnail(&file_path);
124217

125218
if let Err(e) = fs::metadata(&file_path).await {
126219
// A `NotFound` error is expected here, but anything else is unexpected
127220
if e.kind() != std::io::ErrorKind::NotFound {
128221
tracing::error!(error = ?e, "IO error while checking for file existence?");
129222
}
130-
} else if !force_regen {
223+
} else if preserve_existing {
131224
match fs::read(&file_path).await {
132225
Ok(thumbnail) => return Ok((thumbnail, PathBuf::from(&file_path), false)),
133226
Err(e) => {
@@ -188,26 +281,74 @@ pub async fn generate_book_thumbnail(
188281
},
189282
};
190283

191-
let update_result = media::Entity::update_many()
192-
.filter(media::Column::Id.eq(book.id.clone()))
193-
.col_expr(
194-
media::Column::ThumbnailPath,
195-
Expr::value(Some(thumbnail_path.to_string_lossy().to_string())),
196-
)
197-
.col_expr(
198-
media::Column::ThumbnailMeta,
199-
Expr::value(thumbnail_metadata),
200-
)
201-
.exec(conn)
202-
.await;
203-
204-
match update_result {
205-
Ok(_) => Ok((thumbnail, thumbnail_path, did_generate)),
206-
Err(e) => {
207-
tracing::error!(error = ?e, "Failed to update media entity with thumbnail info");
208-
Err(ThumbnailGenerateError::UpdateFailed)
284+
let thumbnail_path_value = thumbnail_path.to_string_lossy().to_string();
285+
let updated_at = Some(DateTimeWithTimeZone::from(Utc::now()));
286+
let update_result = match &target {
287+
ThumbnailTarget::Media => {
288+
media::Entity::update_many()
289+
.filter(media::Column::Id.eq(&book.id))
290+
.col_expr(
291+
media::Column::ThumbnailPath,
292+
Expr::value(Some(thumbnail_path_value)),
293+
)
294+
.col_expr(
295+
media::Column::ThumbnailMeta,
296+
Expr::value(thumbnail_metadata),
297+
)
298+
.col_expr(media::Column::UpdatedAt, Expr::value(updated_at))
299+
.exec(conn)
300+
.await
209301
},
302+
ThumbnailTarget::Series(id) => {
303+
series::Entity::update_many()
304+
.filter(series::Column::Id.eq(id))
305+
.col_expr(
306+
series::Column::ThumbnailPath,
307+
Expr::value(Some(thumbnail_path_value)),
308+
)
309+
.col_expr(
310+
series::Column::ThumbnailMeta,
311+
Expr::value(thumbnail_metadata),
312+
)
313+
.col_expr(series::Column::UpdatedAt, Expr::value(updated_at))
314+
.exec(conn)
315+
.await
316+
},
317+
ThumbnailTarget::Library(id) => {
318+
library::Entity::update_many()
319+
.filter(library::Column::Id.eq(id))
320+
.col_expr(
321+
library::Column::ThumbnailPath,
322+
Expr::value(Some(thumbnail_path_value)),
323+
)
324+
.col_expr(
325+
library::Column::ThumbnailMeta,
326+
Expr::value(thumbnail_metadata),
327+
)
328+
.col_expr(library::Column::UpdatedAt, Expr::value(updated_at))
329+
.exec(conn)
330+
.await
331+
},
332+
};
333+
334+
if let Err(error) = update_result {
335+
tracing::error!(?error, "Failed to update entity with thumbnail info");
336+
return Err(ThumbnailGenerateError::UpdateFailed);
337+
}
338+
339+
if is_custom {
340+
match &target {
341+
ThumbnailTarget::Media => {
342+
bump_media_thumbnail_fallbacks(conn, Some(&book.series_id)).await?
343+
},
344+
ThumbnailTarget::Series(id) => {
345+
bump_series_thumbnail_fallbacks(conn, std::slice::from_ref(id)).await?
346+
},
347+
ThumbnailTarget::Library(_) => {},
348+
}
210349
}
350+
351+
Ok((thumbnail, thumbnail_path, did_generate))
211352
}
212353

213354
/// Copy a book's thumbnail to a target entity (series or library) and update the database.
@@ -260,7 +401,7 @@ where
260401
let dest_path = ctx
261402
.config()
262403
.get_thumbnails_dir()
263-
.join(format!("{}.{}", entity_id, ext));
404+
.join(format!("{}.generated.{}", entity_id, ext));
264405

265406
fs::copy(&source_path, &dest_path).await?;
266407
tracing::debug!(
@@ -297,25 +438,30 @@ async fn generate_series_thumbnail(
297438
ctx: &JobContext,
298439
options: GenerateThumbnailOptions,
299440
) -> Result<GenerateOutput, ThumbnailGenerateError> {
300-
if let (false, Some(thumbnail_path)) = (options.force_regen, &series.thumbnail_path) {
301-
match fs::metadata(thumbnail_path).await {
302-
Ok(_) => {
303-
tracing::debug!(
304-
series_id = %series.id,
305-
?thumbnail_path,
306-
"Thumbnail already exists, skipping generation"
307-
);
308-
let thumbnail_data = fs::read(thumbnail_path).await?;
309-
return Ok((thumbnail_data, PathBuf::from(thumbnail_path), false));
310-
},
311-
Err(error) => {
312-
tracing::debug!(
313-
?error,
314-
series_id = %series.id,
315-
?thumbnail_path,
316-
"Thumbnail path exists in DB but file may be missing, regenerating"
317-
);
318-
},
441+
if let Some(thumbnail_path) = &series.thumbnail_path {
442+
let preserve_existing =
443+
!options.force_regen || !is_generated_thumbnail(Path::new(thumbnail_path));
444+
445+
if preserve_existing {
446+
match fs::metadata(thumbnail_path).await {
447+
Ok(_) => {
448+
tracing::debug!(
449+
series_id = %series.id,
450+
?thumbnail_path,
451+
"Thumbnail already exists, skipping generation"
452+
);
453+
let thumbnail_data = fs::read(thumbnail_path).await?;
454+
return Ok((thumbnail_data, PathBuf::from(thumbnail_path), false));
455+
},
456+
Err(error) => {
457+
tracing::debug!(
458+
?error,
459+
series_id = %series.id,
460+
?thumbnail_path,
461+
"Thumbnail path exists in DB but file may be missing, regenerating"
462+
);
463+
},
464+
}
319465
}
320466
}
321467

@@ -349,6 +495,10 @@ async fn generate_series_thumbnail(
349495
series::Column::ThumbnailMeta,
350496
Expr::value(thumbnail_metadata),
351497
)
498+
.col_expr(
499+
series::Column::UpdatedAt,
500+
Expr::value(Some(DateTimeWithTimeZone::from(Utc::now()))),
501+
)
352502
},
353503
)
354504
.await
@@ -360,26 +510,30 @@ async fn generate_library_thumbnail(
360510
ctx: &JobContext,
361511
options: GenerateThumbnailOptions,
362512
) -> Result<GenerateOutput, ThumbnailGenerateError> {
363-
if let (false, Some(thumbnail_path)) = (options.force_regen, &library.thumbnail_path)
364-
{
365-
match fs::metadata(thumbnail_path).await {
366-
Ok(_) => {
367-
tracing::debug!(
368-
library_id = %library.id,
369-
?thumbnail_path,
370-
"Thumbnail already exists, skipping generation"
371-
);
372-
let thumbnail_data = fs::read(thumbnail_path).await?;
373-
return Ok((thumbnail_data, PathBuf::from(thumbnail_path), false));
374-
},
375-
Err(error) => {
376-
tracing::debug!(
377-
?error,
378-
library_id = %library.id,
379-
?thumbnail_path,
380-
"Thumbnail path exists in DB but file may be missing, regenerating"
381-
);
382-
},
513+
if let Some(thumbnail_path) = &library.thumbnail_path {
514+
let preserve_existing =
515+
!options.force_regen || !is_generated_thumbnail(Path::new(thumbnail_path));
516+
517+
if preserve_existing {
518+
match fs::metadata(thumbnail_path).await {
519+
Ok(_) => {
520+
tracing::debug!(
521+
library_id = %library.id,
522+
?thumbnail_path,
523+
"Thumbnail already exists, skipping generation"
524+
);
525+
let thumbnail_data = fs::read(thumbnail_path).await?;
526+
return Ok((thumbnail_data, PathBuf::from(thumbnail_path), false));
527+
},
528+
Err(error) => {
529+
tracing::debug!(
530+
?error,
531+
library_id = %library.id,
532+
?thumbnail_path,
533+
"Thumbnail path exists in DB but file may be missing, regenerating"
534+
);
535+
},
536+
}
383537
}
384538
}
385539

@@ -415,6 +569,10 @@ async fn generate_library_thumbnail(
415569
library::Column::ThumbnailMeta,
416570
Expr::value(thumbnail_metadata),
417571
)
572+
.col_expr(
573+
library::Column::UpdatedAt,
574+
Expr::value(Some(DateTimeWithTimeZone::from(Utc::now()))),
575+
)
418576
},
419577
)
420578
.await

0 commit comments

Comments
 (0)