View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.thumbnail;
17  
18  import java.io.File;
19  import java.io.IOException;
20  import java.nio.file.FileAlreadyExistsException;
21  import java.nio.file.FileVisitResult;
22  import java.nio.file.FileVisitor;
23  import java.nio.file.Files;
24  import java.nio.file.Path;
25  import java.nio.file.attribute.BasicFileAttributes;
26  import java.util.ArrayList;
27  import java.util.HashMap;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.concurrent.BlockingQueue;
31  import java.util.concurrent.ExecutorService;
32  import java.util.concurrent.LinkedBlockingQueue;
33  import java.util.concurrent.TimeUnit;
34  import java.util.stream.Stream;
35  
36  import org.apache.logging.log4j.LogManager;
37  import org.apache.logging.log4j.Logger;
38  import org.codelibs.core.lang.StringUtil;
39  import org.codelibs.core.lang.ThreadUtil;
40  import org.codelibs.core.misc.Tuple3;
41  import org.codelibs.fess.Constants;
42  import org.codelibs.fess.exception.FessSystemException;
43  import org.codelibs.fess.exception.JobProcessingException;
44  import org.codelibs.fess.helper.SystemHelper;
45  import org.codelibs.fess.mylasta.direction.FessConfig;
46  import org.codelibs.fess.opensearch.client.SearchEngineClient;
47  import org.codelibs.fess.opensearch.config.exbhv.ThumbnailQueueBhv;
48  import org.codelibs.fess.opensearch.config.exentity.ThumbnailQueue;
49  import org.codelibs.fess.util.ComponentUtil;
50  import org.codelibs.fess.util.DocumentUtil;
51  import org.codelibs.fess.util.ResourceUtil;
52  import org.opensearch.index.query.QueryBuilders;
53  
54  import com.google.common.collect.Lists;
55  
56  import jakarta.annotation.PostConstruct;
57  import jakarta.annotation.PreDestroy;
58  
59  /**
60   * Manager class for handling thumbnail generation and management.
61   * Provides functionality to generate, cache, and serve thumbnail images for documents.
62   */
63  public class ThumbnailManager {
64      private static final String NOIMAGE_FILE_SUFFIX = ".txt";
65  
66      /**
67       * Default directory name for thumbnails.
68       */
69      protected static final String THUMBNAILS_DIR_NAME = "thumbnails";
70  
71      /**
72       * Logger instance for this class.
73       */
74      protected static final Logger logger = LogManager.getLogger(ThumbnailManager.class);
75  
76      /**
77       * Base directory for storing thumbnail files.
78       */
79      protected File baseDir;
80  
81      /**
82       * List of available thumbnail generators.
83       */
84      protected final List<ThumbnailGenerator> generatorList = new ArrayList<>();
85  
86      /**
87       * Queue for thumbnail generation tasks containing URL, content, and path tuples.
88       */
89      protected BlockingQueue<Tuple3<String, String, String>> thumbnailTaskQueue;
90  
91      /**
92       * Flag indicating whether thumbnail generation is currently in progress.
93       */
94      protected volatile boolean generating;
95  
96      private Thread thumbnailQueueThread;
97  
98      /**
99       * Size of the thumbnail path cache.
100      */
101     protected int thumbnailPathCacheSize = 10;
102 
103     /**
104      * File extension for generated thumbnail images.
105      */
106     protected String imageExtention = "png";
107 
108     /**
109      * Number of subdirectories for organizing thumbnails.
110      */
111     protected int splitSize = 10;
112 
113     /**
114      * Maximum size of the thumbnail generation task queue.
115      */
116     protected int thumbnailTaskQueueSize = 10000;
117 
118     /**
119      * Number of tasks to process in bulk operations.
120      */
121     protected int thumbnailTaskBulkSize = 100;
122 
123     /**
124      * Timeout in milliseconds for thumbnail task queue operations.
125      */
126     protected long thumbnailTaskQueueTimeout = 10 * 1000L;
127 
128     /**
129      * Expiration time in milliseconds for no-image placeholder files.
130      */
131     protected long noImageExpired = 24 * 60 * 60 * 1000L; // 24 hours
132 
133     /**
134      * Hash size for splitting thumbnail storage directories.
135      */
136     protected int splitHashSize = 10;
137 
138     /**
139      * Default constructor for ThumbnailManager.
140      */
141     public ThumbnailManager() {
142         // Default constructor
143     }
144 
145     /**
146      * Initializes the thumbnail manager after construction.
147      * Sets up base directory and starts background processing.
148      */
149     @PostConstruct
150     public void init() {
151         if (logger.isDebugEnabled()) {
152             logger.debug("Initializing {}", this.getClass().getSimpleName());
153         }
154         final String thumbnailPath = System.getProperty(Constants.FESS_THUMBNAIL_PATH);
155         if (thumbnailPath != null) {
156             baseDir = new File(thumbnailPath);
157         } else {
158             final String varPath = System.getProperty(Constants.FESS_VAR_PATH);
159             if (varPath != null) {
160                 baseDir = new File(varPath, THUMBNAILS_DIR_NAME);
161             } else {
162                 baseDir = ResourceUtil.getThumbnailPath().toFile();
163             }
164         }
165         if (baseDir.mkdirs()) {
166             logger.info("Created thumbnail directory: {}", baseDir.getAbsolutePath());
167         }
168         if (!baseDir.isDirectory()) {
169             throw new FessSystemException("Not found: " + baseDir.getAbsolutePath());
170         }
171 
172         if (logger.isDebugEnabled()) {
173             logger.debug("Thumbnail Directory: {}", baseDir.getAbsolutePath());
174         }
175 
176         thumbnailTaskQueue = new LinkedBlockingQueue<>(thumbnailTaskQueueSize);
177         generating = !Constants.TRUE.equalsIgnoreCase(System.getProperty("fess.thumbnail.process"));
178         thumbnailQueueThread = new Thread((Runnable) () -> {
179             final List<Tuple3<String, String, String>> taskList = new ArrayList<>();
180             while (generating) {
181                 try {
182                     final Tuple3<String, String, String> task = thumbnailTaskQueue.poll(thumbnailTaskQueueTimeout, TimeUnit.MILLISECONDS);
183                     if (task == null) {
184                         if (!taskList.isEmpty()) {
185                             storeQueue(taskList);
186                         }
187                     } else if (!taskList.contains(task)) {
188                         taskList.add(task);
189                         if (taskList.size() > thumbnailTaskBulkSize) {
190                             storeQueue(taskList);
191                         }
192                     }
193                 } catch (final InterruptedException e) {
194                     if (generating && logger.isDebugEnabled()) {
195                         logger.debug("Interrupted task.", e);
196                     }
197                 } catch (final Exception e) {
198                     if (generating) {
199                         logger.warn("Failed to generate thumbnail.", e);
200                     }
201                 }
202             }
203             if (!taskList.isEmpty()) {
204                 storeQueue(taskList);
205             }
206         }, "ThumbnailGenerator");
207         thumbnailQueueThread.start();
208     }
209 
210     /**
211      * Cleans up resources when the thumbnail manager is destroyed.
212      * Stops background processing and waits for threads to complete.
213      */
214     @PreDestroy
215     public void destroy() {
216         generating = false;
217         thumbnailQueueThread.interrupt();
218         try {
219             thumbnailQueueThread.join(10000);
220         } catch (final InterruptedException e) {
221             logger.warn("Thumbnail thread timed out.", e);
222         }
223         generatorList.forEach(g -> {
224             try {
225                 g.destroy();
226             } catch (final Exception e) {
227                 logger.warn("Failed to stop thumbnail generator.", e);
228             }
229         });
230     }
231 
232     /**
233      * Gets the system property option string for thumbnail path configuration.
234      *
235      * @return the property option string for JVM arguments
236      */
237     public String getThumbnailPathOption() {
238         return "-D" + Constants.FESS_THUMBNAIL_PATH + "=" + baseDir.getAbsolutePath();
239     }
240 
241     /**
242      * Stores thumbnail generation tasks to the queue for processing.
243      *
244      * @param taskList list of thumbnail tasks to store
245      */
246     protected void storeQueue(final List<Tuple3<String, String, String>> taskList) {
247         final FessConfig fessConfig = ComponentUtil.getFessConfig();
248         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
249         final String[] targets = fessConfig.getThumbnailGeneratorTargetsAsArray();
250         final List<ThumbnailQueue> list = new ArrayList<>();
251         taskList.stream().filter(entity -> entity != null).forEach(task -> {
252             for (final String target : targets) {
253                 final ThumbnailQueue entity = new ThumbnailQueue();
254                 entity.setGenerator(task.getValue1());
255                 entity.setThumbnailId(task.getValue2());
256                 entity.setPath(task.getValue3());
257                 entity.setTarget(target);
258                 entity.setCreatedBy(Constants.SYSTEM_USER);
259                 entity.setCreatedTime(systemHelper.getCurrentTimeAsLong());
260                 list.add(entity);
261             }
262         });
263         taskList.clear();
264         if (logger.isDebugEnabled()) {
265             logger.debug("Storing {} thumbnail tasks.", list.size());
266         }
267         final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
268         thumbnailQueueBhv.batchInsert(list);
269     }
270 
271     /**
272      * Generates thumbnails using the provided executor service.
273      *
274      * @param executorService the executor service for parallel processing
275      * @param cleanup whether to run in cleanup mode
276      * @return the number of tasks processed
277      */
278     public int generate(final ExecutorService executorService, final boolean cleanup) {
279         final FessConfig fessConfig = ComponentUtil.getFessConfig();
280         final List<String> idList = new ArrayList<>();
281         final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
282         thumbnailQueueBhv.selectList(cb -> {
283             if (StringUtil.isBlank(fessConfig.getSchedulerTargetName())) {
284                 cb.query().setTarget_Equal(Constants.DEFAULT_JOB_TARGET);
285             } else {
286                 cb.query().setTarget_InScope(Lists.newArrayList(Constants.DEFAULT_JOB_TARGET, fessConfig.getSchedulerTargetName()));
287             }
288             cb.query().addOrderBy_CreatedTime_Asc();
289             cb.fetchFirst(fessConfig.getPageThumbnailQueueMaxFetchSizeAsInteger());
290         }).stream().map(entity -> {
291             idList.add(entity.getId());
292             if (!cleanup) {
293                 return executorService.submit(() -> process(fessConfig, entity));
294             }
295             if (logger.isDebugEnabled()) {
296                 logger.debug("Removing thumbnail queue: {}", entity);
297             }
298             return null;
299         }).filter(f -> f != null).forEach(f -> {
300             try {
301                 f.get();
302             } catch (final Exception e) {
303                 logger.warn("Failed to process a thumbnail generation.", e);
304             }
305         });
306 
307         if (!idList.isEmpty()) {
308             thumbnailQueueBhv.queryDelete(cb -> {
309                 cb.query().setId_InScope(idList);
310             });
311             thumbnailQueueBhv.refresh();
312         }
313         return idList.size();
314     }
315 
316     /**
317      * Processes a single thumbnail generation task.
318      *
319      * @param fessConfig the Fess configuration
320      * @param entity the thumbnail queue entity to process
321      */
322     protected void process(final FessConfig fessConfig, final ThumbnailQueue entity) {
323         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
324         systemHelper.calibrateCpuLoad();
325 
326         if (logger.isDebugEnabled()) {
327             logger.debug("Processing thumbnail: {}", entity);
328         }
329         final String generatorName = entity.getGenerator();
330         try {
331             final File outputFile = new File(baseDir, entity.getPath());
332             final File noImageFile = new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX);
333             final Path noImagePath = noImageFile.toPath();
334             if (!noImageFile.isFile() || systemHelper.getCurrentTimeAsLong() - noImageFile.lastModified() > noImageExpired) {
335                 try {
336                     Files.deleteIfExists(noImagePath);
337                 } catch (final IOException e) {
338                     logger.warn("Failed to delete no-image file: {}", noImageFile.getAbsolutePath(), e);
339                 }
340                 final ThumbnailGenerator generator = ComponentUtil.getComponent(generatorName);
341                 if (generator.isAvailable()) {
342                     if (!generator.generate(entity.getThumbnailId(), outputFile)) {
343                         new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX).setLastModified(systemHelper.getCurrentTimeAsLong());
344                     } else {
345                         final long interval = fessConfig.getThumbnailGeneratorIntervalAsInteger().longValue();
346                         if (interval > 0) {
347                             ThreadUtil.sleep(interval);
348                         }
349                     }
350                 } else {
351                     logger.warn("Thumbnail generator is not available: name={}", generatorName);
352                 }
353             } else if (logger.isDebugEnabled()) {
354                 logger.debug("No image file exists: {}", noImageFile.getAbsolutePath());
355             }
356         } catch (final Exception e) {
357             logger.warn("Failed to create thumbnail for {}", entity, e);
358         }
359     }
360 
361     /**
362      * Offers a document for thumbnail generation.
363      *
364      * @param docMap the document data map
365      * @return true if the task was successfully added to the queue
366      */
367     public boolean offer(final Map<String, Object> docMap) {
368         for (final ThumbnailGenerator generator : generatorList) {
369             if (generator.isTarget(docMap)) {
370                 final String path = getImageFilename(docMap);
371                 final Tuple3<String, String, String> task = generator.createTask(path, docMap);
372                 if (task != null) {
373                     if (logger.isDebugEnabled()) {
374                         logger.debug("Add thumbnail task: {}", task);
375                     }
376                     if (!thumbnailTaskQueue.offer(task)) {
377                         logger.warn("Failed to add thumbnail task: {}", task);
378                     }
379                     return true;
380                 }
381                 return false;
382             }
383         }
384         if (logger.isDebugEnabled()) {
385             logger.debug("Thumbnail generator is not found: {}", docMap != null ? docMap.get("url") : docMap);
386         }
387         return false;
388     }
389 
390     /**
391      * Gets the image filename for a document based on its document map.
392      *
393      * @param docMap the document data map
394      * @return the generated image filename
395      */
396     protected String getImageFilename(final Map<String, Object> docMap) {
397         final FessConfig fessConfig = ComponentUtil.getFessConfig();
398         final String docid = DocumentUtil.getValue(docMap, fessConfig.getIndexFieldDocId(), String.class);
399         return getImageFilename(docid);
400     }
401 
402     /**
403      * Gets the image filename for a document based on its document ID.
404      *
405      * @param docid the document ID
406      * @return the generated image filename
407      */
408     protected String getImageFilename(final String docid) {
409         final StringBuilder buf = new StringBuilder(50);
410         for (int i = 0; i < docid.length(); i += splitSize) {
411             int hash = docid.substring(i).hashCode() % splitHashSize;
412             if (hash < 0) {
413                 hash *= -1;
414             }
415             buf.append('_').append(Integer.toString(hash)).append('/');
416         }
417         buf.append(docid).append('.').append(imageExtention);
418         return buf.toString();
419     }
420 
421     /**
422      * Gets the thumbnail file for a document if it exists.
423      *
424      * @param docMap the document data map
425      * @return the thumbnail file or null if not found
426      */
427     public File getThumbnailFile(final Map<String, Object> docMap) {
428         final String thumbnailPath = getImageFilename(docMap);
429         if (StringUtil.isNotBlank(thumbnailPath)) {
430             final File file = new File(baseDir, thumbnailPath);
431             if (file.isFile()) {
432                 return file;
433             }
434         }
435         return null;
436     }
437 
438     /**
439      * Adds a thumbnail generator to the manager.
440      *
441      * @param generator the thumbnail generator to add
442      */
443     public void add(final ThumbnailGenerator generator) {
444         if (generator.isAvailable()) {
445             if (logger.isDebugEnabled()) {
446                 logger.debug("{} is available.", generator.getName());
447             }
448             generatorList.add(generator);
449         } else if (logger.isDebugEnabled()) {
450             logger.debug("{} is not available.", generator.getName());
451         }
452     }
453 
454     /**
455      * Purges old thumbnail files based on the expiry time.
456      *
457      * @param expiry the expiry time threshold
458      * @return the number of files purged
459      */
460     public long purge(final long expiry) {
461         if (!baseDir.exists()) {
462             return 0;
463         }
464         try {
465             final FilePurgeVisitor visitor = new FilePurgeVisitor(baseDir.toPath(), imageExtention, expiry);
466             Files.walkFileTree(baseDir.toPath(), visitor);
467             return visitor.getCount();
468         } catch (final Exception e) {
469             throw new JobProcessingException(e);
470         }
471     }
472 
473     /**
474      * File visitor for purging old thumbnail files.
475      */
476     protected static class FilePurgeVisitor implements FileVisitor<Path> {
477 
478         /**
479          * Expiry time threshold for file deletion.
480          */
481         protected final long expiry;
482 
483         /**
484          * Count of processed files.
485          */
486         protected long count;
487 
488         /**
489          * Maximum number of files to purge in a single operation.
490          */
491         protected final int maxPurgeSize;
492 
493         /**
494          * List of files marked for deletion.
495          */
496         protected final List<Path> deletedFileList = new ArrayList<>();
497 
498         /**
499          * Base path for thumbnail storage.
500          */
501         protected final Path basePath;
502 
503         /**
504          * Image file extension for filtering.
505          */
506         protected final String imageExtention;
507 
508         /**
509          * OpenSearch client for document validation.
510          */
511         protected final SearchEngineClient searchEngineClient;
512 
513         /**
514          * Fess configuration for settings.
515          */
516         protected final FessConfig fessConfig;
517 
518         FilePurgeVisitor(final Path basePath, final String imageExtention, final long expiry) {
519             this.basePath = basePath;
520             this.imageExtention = imageExtention;
521             this.expiry = expiry;
522             fessConfig = ComponentUtil.getFessConfig();
523             maxPurgeSize = fessConfig.getPageThumbnailPurgeMaxFetchSizeAsInteger();
524             searchEngineClient = ComponentUtil.getSearchEngineClient();
525         }
526 
527         /**
528          * Deletes files marked for deletion after checking if they exist in the search index.
529          */
530         protected void deleteFiles() {
531             final Map<String, Path> deleteFileMap = new HashMap<>();
532             for (final Path path : deletedFileList) {
533                 final String docId = getDocId(path);
534                 if (StringUtil.isBlank(docId) || deleteFileMap.containsKey(docId)) {
535                     deleteFile(path);
536                 } else {
537                     deleteFileMap.put(docId, path);
538                 }
539             }
540             deletedFileList.clear();
541 
542             if (!deleteFileMap.isEmpty()) {
543                 final String docIdField = fessConfig.getIndexFieldDocId();
544                 searchEngineClient.getDocumentList(fessConfig.getIndexDocumentSearchIndex(), searchRequestBuilder -> {
545                     searchRequestBuilder.setQuery(
546                             QueryBuilders.termsQuery(docIdField, deleteFileMap.keySet().toArray(new String[deleteFileMap.size()])));
547                     searchRequestBuilder.setFetchSource(new String[] { docIdField }, StringUtil.EMPTY_STRINGS);
548                     return true;
549                 }).forEach(m -> {
550                     final Object docId = m.get(docIdField);
551                     if (docId != null) {
552                         deleteFileMap.remove(docId);
553                         if (logger.isDebugEnabled()) {
554                             logger.debug("Keep thumbnail: {}", docId);
555                         }
556                     }
557                 });
558 
559                 deleteFileMap.values().forEach(this::deleteFile);
560                 count += deleteFileMap.size();
561             }
562         }
563 
564         /**
565          * Deletes a single file and any empty parent directories.
566          *
567          * @param path the file path to delete
568          */
569         protected void deleteFile(final Path path) {
570             try {
571                 Files.delete(path);
572                 if (logger.isDebugEnabled()) {
573                     logger.debug("Deleted thumbnail file: {}", path);
574                 }
575 
576                 Path parent = path.getParent();
577                 while (deleteEmptyDirectory(parent)) {
578                     parent = parent.getParent();
579                 }
580             } catch (final IOException e) {
581                 logger.warn("Failed to delete thumbnail file: {}", path, e);
582             }
583         }
584 
585         /**
586          * Extracts the document ID from a file path.
587          *
588          * @param file the file path
589          * @return the extracted document ID
590          */
591         protected String getDocId(final Path file) {
592             final String s = file.toUri().toString();
593             final String b = basePath.toUri().toString();
594             final String id = s.replace(b, StringUtil.EMPTY).replace("." + imageExtention, StringUtil.EMPTY).replace("/", StringUtil.EMPTY);
595             if (logger.isDebugEnabled()) {
596                 logger.debug("Base: {} File: {} DocId: {}", b, s, id);
597             }
598             return id;
599         }
600 
601         /**
602          * Gets the count of processed files.
603          *
604          * @return the number of files processed
605          */
606         public long getCount() {
607             if (!deletedFileList.isEmpty()) {
608                 deleteFiles();
609             }
610             return count;
611         }
612 
613         @Override
614         public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException {
615             return FileVisitResult.CONTINUE;
616         }
617 
618         @Override
619         public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
620             if (ComponentUtil.getSystemHelper().getCurrentTimeAsLong() - Files.getLastModifiedTime(file).toMillis() > expiry) {
621                 deletedFileList.add(file);
622                 if (deletedFileList.size() > maxPurgeSize) {
623                     deleteFiles();
624                 }
625             }
626             return FileVisitResult.CONTINUE;
627         }
628 
629         @Override
630         public FileVisitResult visitFileFailed(final Path file, final IOException e) throws IOException {
631             if (e != null) {
632                 logger.warn("I/O exception on {}", file, e);
633             }
634             return FileVisitResult.CONTINUE;
635         }
636 
637         @Override
638         public FileVisitResult postVisitDirectory(final Path dir, final IOException e) throws IOException {
639             if (e != null) {
640                 logger.warn("I/O exception on {}", dir, e);
641             }
642             deleteEmptyDirectory(dir);
643             return FileVisitResult.CONTINUE;
644         }
645 
646         private boolean deleteEmptyDirectory(final Path dir) throws IOException {
647             if (dir == null) {
648                 return false;
649             }
650             final File directory = dir.toFile();
651             if (directory.list() != null && directory.list().length == 0 && !THUMBNAILS_DIR_NAME.equals(directory.getName())) {
652                 Files.delete(dir);
653                 if (logger.isDebugEnabled()) {
654                     logger.debug("Deleted empty directory: {}", dir);
655                 }
656                 return true;
657             }
658             return false;
659         }
660 
661     }
662 
663     /**
664      * Migrates existing thumbnail files to the new directory structure.
665      */
666     public void migrate() {
667         new Thread(() -> {
668             final Path basePath = baseDir.toPath();
669             final String suffix = "." + imageExtention;
670             try (Stream<Path> paths = Files.walk(basePath)) {
671                 paths.filter(path -> path.toFile().getName().endsWith(imageExtention)).forEach(path -> {
672                     final Path subPath = basePath.relativize(path);
673                     final String docId = subPath.toString().replace("/", StringUtil.EMPTY).replace(suffix, StringUtil.EMPTY);
674                     if (!docId.startsWith("_")) {
675                         final String filename = getImageFilename(docId);
676                         final Path newPath = basePath.resolve(filename);
677                         if (!path.equals(newPath)) {
678                             try {
679                                 try {
680                                     Files.createDirectories(newPath.getParent());
681                                 } catch (final FileAlreadyExistsException e) {
682                                     // ignore
683                                 }
684                                 Files.move(path, newPath);
685                                 logger.info("Moving thumbnail: from={}, to={}", path, newPath);
686                             } catch (final IOException e) {
687                                 logger.warn("Failed to move thumbnail: path={}", path, e);
688                             }
689                         }
690                     }
691                 });
692             } catch (final IOException e) {
693                 logger.warn("Failed to migrate thumbnail images.", e);
694             }
695         }, "ThumbnailMigrator").start();
696     }
697 
698     /**
699      * Sets the thumbnail path cache size.
700      *
701      * @param thumbnailPathCacheSize the cache size to set
702      */
703     public void setThumbnailPathCacheSize(final int thumbnailPathCacheSize) {
704         this.thumbnailPathCacheSize = thumbnailPathCacheSize;
705     }
706 
707     /**
708      * Sets the image file extension for thumbnails.
709      *
710      * @param imageExtention the file extension to set
711      */
712     public void setImageExtention(final String imageExtention) {
713         this.imageExtention = imageExtention;
714     }
715 
716     /**
717      * Sets the split size for directory organization.
718      *
719      * @param splitSize the split size to set
720      */
721     public void setSplitSize(final int splitSize) {
722         this.splitSize = splitSize;
723     }
724 
725     /**
726      * Sets the maximum size of the thumbnail task queue.
727      *
728      * @param thumbnailTaskQueueSize the queue size to set
729      */
730     public void setThumbnailTaskQueueSize(final int thumbnailTaskQueueSize) {
731         this.thumbnailTaskQueueSize = thumbnailTaskQueueSize;
732     }
733 
734     /**
735      * Sets the expiration time for no-image placeholder files.
736      *
737      * @param noImageExpired the expiration time in milliseconds
738      */
739     public void setNoImageExpired(final long noImageExpired) {
740         this.noImageExpired = noImageExpired;
741     }
742 
743     /**
744      * Sets the hash size for splitting thumbnail storage directories.
745      *
746      * @param splitHashSize the hash size to set
747      */
748     public void setSplitHashSize(final int splitHashSize) {
749         this.splitHashSize = splitHashSize;
750     }
751 
752 }