1- use std:: path:: PathBuf ;
1+ use std:: path:: { Path , PathBuf } ;
22
3+ use chrono:: Utc ;
34use futures:: { stream:: FuturesUnordered , StreamExt } ;
45use models:: {
56 entity:: { library, media, series} ,
@@ -9,8 +10,8 @@ use models::{
910 } ,
1011} ;
1112use 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} ;
1516use 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