1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
61
62
63 public class ThumbnailManager {
64 private static final String NOIMAGE_FILE_SUFFIX = ".txt";
65
66
67
68
69 protected static final String THUMBNAILS_DIR_NAME = "thumbnails";
70
71
72
73
74 protected static final Logger logger = LogManager.getLogger(ThumbnailManager.class);
75
76
77
78
79 protected File baseDir;
80
81
82
83
84 protected final List<ThumbnailGenerator> generatorList = new ArrayList<>();
85
86
87
88
89 protected BlockingQueue<Tuple3<String, String, String>> thumbnailTaskQueue;
90
91
92
93
94 protected volatile boolean generating;
95
96 private Thread thumbnailQueueThread;
97
98
99
100
101 protected int thumbnailPathCacheSize = 10;
102
103
104
105
106 protected String imageExtention = "png";
107
108
109
110
111 protected int splitSize = 10;
112
113
114
115
116 protected int thumbnailTaskQueueSize = 10000;
117
118
119
120
121 protected int thumbnailTaskBulkSize = 100;
122
123
124
125
126 protected long thumbnailTaskQueueTimeout = 10 * 1000L;
127
128
129
130
131 protected long noImageExpired = 24 * 60 * 60 * 1000L;
132
133
134
135
136 protected int splitHashSize = 10;
137
138
139
140
141 public ThumbnailManager() {
142
143 }
144
145
146
147
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
212
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
234
235
236
237 public String getThumbnailPathOption() {
238 return "-D" + Constants.FESS_THUMBNAIL_PATH + "=" + baseDir.getAbsolutePath();
239 }
240
241
242
243
244
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
273
274
275
276
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
318
319
320
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
363
364
365
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
392
393
394
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
404
405
406
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
423
424
425
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
440
441
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
456
457
458
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
475
476 protected static class FilePurgeVisitor implements FileVisitor<Path> {
477
478
479
480
481 protected final long expiry;
482
483
484
485
486 protected long count;
487
488
489
490
491 protected final int maxPurgeSize;
492
493
494
495
496 protected final List<Path> deletedFileList = new ArrayList<>();
497
498
499
500
501 protected final Path basePath;
502
503
504
505
506 protected final String imageExtention;
507
508
509
510
511 protected final SearchEngineClient searchEngineClient;
512
513
514
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
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
566
567
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
587
588
589
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
603
604
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
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
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
700
701
702
703 public void setThumbnailPathCacheSize(final int thumbnailPathCacheSize) {
704 this.thumbnailPathCacheSize = thumbnailPathCacheSize;
705 }
706
707
708
709
710
711
712 public void setImageExtention(final String imageExtention) {
713 this.imageExtention = imageExtention;
714 }
715
716
717
718
719
720
721 public void setSplitSize(final int splitSize) {
722 this.splitSize = splitSize;
723 }
724
725
726
727
728
729
730 public void setThumbnailTaskQueueSize(final int thumbnailTaskQueueSize) {
731 this.thumbnailTaskQueueSize = thumbnailTaskQueueSize;
732 }
733
734
735
736
737
738
739 public void setNoImageExpired(final long noImageExpired) {
740 this.noImageExpired = noImageExpired;
741 }
742
743
744
745
746
747
748 public void setSplitHashSize(final int splitHashSize) {
749 this.splitHashSize = splitHashSize;
750 }
751
752 }