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 javax.annotation.PostConstruct;
37 import javax.annotation.PreDestroy;
38
39 import org.apache.logging.log4j.LogManager;
40 import org.apache.logging.log4j.Logger;
41 import org.codelibs.core.lang.StringUtil;
42 import org.codelibs.core.lang.ThreadUtil;
43 import org.codelibs.core.misc.Tuple3;
44 import org.codelibs.fesen.index.query.QueryBuilders;
45 import org.codelibs.fess.Constants;
46 import org.codelibs.fess.es.client.SearchEngineClient;
47 import org.codelibs.fess.es.config.exbhv.ThumbnailQueueBhv;
48 import org.codelibs.fess.es.config.exentity.ThumbnailQueue;
49 import org.codelibs.fess.exception.FessSystemException;
50 import org.codelibs.fess.exception.JobProcessingException;
51 import org.codelibs.fess.helper.SystemHelper;
52 import org.codelibs.fess.mylasta.direction.FessConfig;
53 import org.codelibs.fess.util.ComponentUtil;
54 import org.codelibs.fess.util.DocumentUtil;
55 import org.codelibs.fess.util.ResourceUtil;
56
57 import com.google.common.collect.Lists;
58
59 public class ThumbnailManager {
60 private static final String NOIMAGE_FILE_SUFFIX = ".txt";
61
62 protected static final String THUMBNAILS_DIR_NAME = "thumbnails";
63
64 private static final Logger logger = LogManager.getLogger(ThumbnailManager.class);
65
66 protected File baseDir;
67
68 private final List<ThumbnailGenerator> generatorList = new ArrayList<>();
69
70 private BlockingQueue<Tuple3<String, String, String>> thumbnailTaskQueue;
71
72 private volatile boolean generating;
73
74 private Thread thumbnailQueueThread;
75
76 protected int thumbnailPathCacheSize = 10;
77
78 protected String imageExtention = "png";
79
80 protected int splitSize = 3;
81
82 protected int thumbnailTaskQueueSize = 10000;
83
84 protected int thumbnailTaskBulkSize = 100;
85
86 protected long thumbnailTaskQueueTimeout = 10 * 1000L;
87
88 protected long noImageExpired = 24 * 60 * 60 * 1000L;
89
90 @PostConstruct
91 public void init() {
92 if (logger.isDebugEnabled()) {
93 logger.debug("Initialize {}", this.getClass().getSimpleName());
94 }
95 final String thumbnailPath = System.getProperty(Constants.FESS_THUMBNAIL_PATH);
96 if (thumbnailPath != null) {
97 baseDir = new File(thumbnailPath);
98 } else {
99 final String varPath = System.getProperty(Constants.FESS_VAR_PATH);
100 if (varPath != null) {
101 baseDir = new File(varPath, THUMBNAILS_DIR_NAME);
102 } else {
103 baseDir = ResourceUtil.getThumbnailPath().toFile();
104 }
105 }
106 if (baseDir.mkdirs()) {
107 logger.info("Created: {}", baseDir.getAbsolutePath());
108 }
109 if (!baseDir.isDirectory()) {
110 throw new FessSystemException("Not found: " + baseDir.getAbsolutePath());
111 }
112
113 if (logger.isDebugEnabled()) {
114 logger.debug("Thumbnail Directory: {}", baseDir.getAbsolutePath());
115 }
116
117 thumbnailTaskQueue = new LinkedBlockingQueue<>(thumbnailTaskQueueSize);
118 generating = !Constants.TRUE.equalsIgnoreCase(System.getProperty("fess.thumbnail.process"));
119 thumbnailQueueThread = new Thread((Runnable) () -> {
120 final List<Tuple3<String, String, String>> taskList = new ArrayList<>();
121 while (generating) {
122 try {
123 final Tuple3<String, String, String> task = thumbnailTaskQueue.poll(thumbnailTaskQueueTimeout, TimeUnit.MILLISECONDS);
124 if (task == null) {
125 if (!taskList.isEmpty()) {
126 storeQueue(taskList);
127 }
128 } else if (!taskList.contains(task)) {
129 taskList.add(task);
130 if (taskList.size() > thumbnailTaskBulkSize) {
131 storeQueue(taskList);
132 }
133 }
134 } catch (final InterruptedException e) {
135 if (logger.isDebugEnabled()) {
136 logger.debug("Interupted task.", e);
137 }
138 } catch (final Exception e) {
139 if (generating) {
140 logger.warn("Failed to generate thumbnail.", e);
141 }
142 }
143 }
144 if (!taskList.isEmpty()) {
145 storeQueue(taskList);
146 }
147 }, "ThumbnailGenerator");
148 thumbnailQueueThread.start();
149 }
150
151 @PreDestroy
152 public void destroy() {
153 generating = false;
154 thumbnailQueueThread.interrupt();
155 try {
156 thumbnailQueueThread.join(10000);
157 } catch (final InterruptedException e) {
158 logger.warn("Thumbnail thread is timeouted.", e);
159 }
160 generatorList.forEach(g -> {
161 try {
162 g.destroy();
163 } catch (final Exception e) {
164 logger.warn("Failed to stop thumbnail generator.", e);
165 }
166 });
167 }
168
169 public String getThumbnailPathOption() {
170 return "-D" + Constants.FESS_THUMBNAIL_PATH + "=" + baseDir.getAbsolutePath();
171 }
172
173 protected void storeQueue(final List<Tuple3<String, String, String>> taskList) {
174 final FessConfig fessConfig = ComponentUtil.getFessConfig();
175 final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
176 final String[] targets = fessConfig.getThumbnailGeneratorTargetsAsArray();
177 final List<ThumbnailQueue> list = new ArrayList<>();
178 taskList.stream().filter(entity -> entity != null).forEach(task -> {
179 for (final String target : targets) {
180 final ThumbnailQueue entity = new ThumbnailQueue();
181 entity.setGenerator(task.getValue1());
182 entity.setThumbnailId(task.getValue2());
183 entity.setPath(task.getValue3());
184 entity.setTarget(target);
185 entity.setCreatedBy(Constants.SYSTEM_USER);
186 entity.setCreatedTime(systemHelper.getCurrentTimeAsLong());
187 list.add(entity);
188 }
189 });
190 taskList.clear();
191 if (logger.isDebugEnabled()) {
192 logger.debug("Storing {} thumbnail tasks.", list.size());
193 }
194 final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
195 thumbnailQueueBhv.batchInsert(list);
196 }
197
198 public int generate(final ExecutorService executorService, final boolean cleanup) {
199 final FessConfig fessConfig = ComponentUtil.getFessConfig();
200 final List<String> idList = new ArrayList<>();
201 final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
202 thumbnailQueueBhv.selectList(cb -> {
203 if (StringUtil.isBlank(fessConfig.getSchedulerTargetName())) {
204 cb.query().setTarget_Equal(Constants.DEFAULT_JOB_TARGET);
205 } else {
206 cb.query().setTarget_InScope(Lists.newArrayList(Constants.DEFAULT_JOB_TARGET, fessConfig.getSchedulerTargetName()));
207 }
208 cb.query().addOrderBy_CreatedTime_Asc();
209 cb.fetchFirst(fessConfig.getPageThumbnailQueueMaxFetchSizeAsInteger());
210 }).stream().map(entity -> {
211 idList.add(entity.getId());
212 if (!cleanup) {
213 return executorService.submit(() -> process(fessConfig, entity));
214 }
215 if (logger.isDebugEnabled()) {
216 logger.debug("Removing thumbnail queue: {}", entity);
217 }
218 return null;
219 }).filter(f -> f != null).forEach(f -> {
220 try {
221 f.get();
222 } catch (final Exception e) {
223 logger.warn("Failed to process a thumbnail generation.", e);
224 }
225 });
226
227 if (!idList.isEmpty()) {
228 thumbnailQueueBhv.queryDelete(cb -> {
229 cb.query().setId_InScope(idList);
230 });
231 thumbnailQueueBhv.refresh();
232 }
233 return idList.size();
234 }
235
236 protected void process(final FessConfig fessConfig, final ThumbnailQueue entity) {
237 ComponentUtil.getSystemHelper().calibrateCpuLoad();
238
239 if (logger.isDebugEnabled()) {
240 logger.debug("Processing thumbnail: {}", entity);
241 }
242 final String generatorName = entity.getGenerator();
243 try {
244 final File outputFile = new File(baseDir, entity.getPath());
245 final File noImageFile = new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX);
246 if (!noImageFile.isFile() || System.currentTimeMillis() - noImageFile.lastModified() > noImageExpired) {
247 if (noImageFile.isFile() && !noImageFile.delete()) {
248 logger.warn("Failed to delete {}", noImageFile.getAbsolutePath());
249 }
250 final ThumbnailGenerator generator = ComponentUtil.getComponent(generatorName);
251 if (generator.isAvailable()) {
252 if (!generator.generate(entity.getThumbnailId(), outputFile)) {
253 new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX).setLastModified(System.currentTimeMillis());
254 } else {
255 final long interval = fessConfig.getThumbnailGeneratorIntervalAsInteger().longValue();
256 if (interval > 0) {
257 ThreadUtil.sleep(interval);
258 }
259 }
260 } else {
261 logger.warn("{} is not available.", generatorName);
262 }
263 } else if (logger.isDebugEnabled()) {
264 logger.debug("No image file exists: {}", noImageFile.getAbsolutePath());
265 }
266 } catch (final Exception e) {
267 logger.warn("Failed to create thumbnail for {}", entity, e);
268 }
269 }
270
271 public boolean offer(final Map<String, Object> docMap) {
272 for (final ThumbnailGenerator generator : generatorList) {
273 if (generator.isTarget(docMap)) {
274 final String path = getImageFilename(docMap);
275 final Tuple3<String, String, String> task = generator.createTask(path, docMap);
276 if (task != null) {
277 if (logger.isDebugEnabled()) {
278 logger.debug("Add thumbnail task: {}", task);
279 }
280 if (!thumbnailTaskQueue.offer(task)) {
281 logger.warn("Failed to add thumbnail task: {}", task);
282 }
283 return true;
284 }
285 return false;
286 }
287 }
288 if (logger.isDebugEnabled()) {
289 logger.debug("Thumbnail generator is not found: {}", (docMap != null ? docMap.get("url") : docMap));
290 }
291 return false;
292 }
293
294 protected String getImageFilename(final Map<String, Object> docMap) {
295 final FessConfig fessConfig = ComponentUtil.getFessConfig();
296 final String docid = DocumentUtil.getValue(docMap, fessConfig.getIndexFieldDocId(), String.class);
297 return getImageFilename(docid);
298 }
299
300 protected String getImageFilename(final String docid) {
301 final StringBuilder buf = new StringBuilder(50);
302 for (int i = 0; i < docid.length(); i++) {
303 if (i > 0 && i % splitSize == 0) {
304 buf.append('/');
305 }
306 buf.append(docid.charAt(i));
307 }
308 buf.append('.').append(imageExtention);
309 return buf.toString();
310 }
311
312 public File getThumbnailFile(final Map<String, Object> docMap) {
313 final String thumbnailPath = getImageFilename(docMap);
314 if (StringUtil.isNotBlank(thumbnailPath)) {
315 final File file = new File(baseDir, thumbnailPath);
316 if (file.isFile()) {
317 return file;
318 }
319 }
320 return null;
321 }
322
323 public void add(final ThumbnailGenerator generator) {
324 if (logger.isDebugEnabled()) {
325 logger.debug("{} is available.", generator.getName());
326 }
327 if (generator.isAvailable()) {
328 generatorList.add(generator);
329 }
330 }
331
332 public long purge(final long expiry) {
333 if (!baseDir.exists()) {
334 return 0;
335 }
336 try {
337 final FilePurgeVisitor visitor = new FilePurgeVisitor(baseDir.toPath(), imageExtention, expiry);
338 Files.walkFileTree(baseDir.toPath(), visitor);
339 return visitor.getCount();
340 } catch (final Exception e) {
341 throw new JobProcessingException(e);
342 }
343 }
344
345 protected static class FilePurgeVisitor implements FileVisitor<Path> {
346
347 protected final long expiry;
348
349 protected long count;
350
351 protected final int maxPurgeSize;
352
353 protected final List<Path> deletedFileList = new ArrayList<>();
354
355 protected final Path basePath;
356
357 protected final String imageExtention;
358
359 protected final SearchEngineClient searchEngineClient;
360
361 protected final FessConfig fessConfig;
362
363 FilePurgeVisitor(final Path basePath, final String imageExtention, final long expiry) {
364 this.basePath = basePath;
365 this.imageExtention = imageExtention;
366 this.expiry = expiry;
367 this.fessConfig = ComponentUtil.getFessConfig();
368 this.maxPurgeSize = fessConfig.getPageThumbnailPurgeMaxFetchSizeAsInteger();
369 this.searchEngineClient = ComponentUtil.getSearchEngineClient();
370 }
371
372 protected void deleteFiles() {
373 final Map<String, Path> deleteFileMap = new HashMap<>();
374 for (final Path path : deletedFileList) {
375 final String docId = getDocId(path);
376 if (StringUtil.isBlank(docId) || deleteFileMap.containsKey(docId)) {
377 deleteFile(path);
378 } else {
379 deleteFileMap.put(docId, path);
380 }
381 }
382 deletedFileList.clear();
383
384 if (!deleteFileMap.isEmpty()) {
385 final String docIdField = fessConfig.getIndexFieldDocId();
386 searchEngineClient.getDocumentList(fessConfig.getIndexDocumentSearchIndex(), searchRequestBuilder -> {
387 searchRequestBuilder.setQuery(
388 QueryBuilders.termsQuery(docIdField, deleteFileMap.keySet().toArray(new String[deleteFileMap.size()])));
389 searchRequestBuilder.setFetchSource(new String[] { docIdField }, StringUtil.EMPTY_STRINGS);
390 return true;
391 }).forEach(m -> {
392 final Object docId = m.get(docIdField);
393 if (docId != null) {
394 deleteFileMap.remove(docId);
395 if (logger.isDebugEnabled()) {
396 logger.debug("Keep thumbnail: {}", docId);
397 }
398 }
399 });
400
401 deleteFileMap.values().forEach(this::deleteFile);
402 count += deleteFileMap.size();
403 }
404 }
405
406 protected void deleteFile(final Path path) {
407 try {
408 Files.delete(path);
409 if (logger.isDebugEnabled()) {
410 logger.debug("Delete {}", path);
411 }
412
413 Path parent = path.getParent();
414 while (deleteEmptyDirectory(parent)) {
415 parent = parent.getParent();
416 }
417 } catch (final IOException e) {
418 logger.warn("Failed to delete {}", path, e);
419 }
420 }
421
422 protected String getDocId(final Path file) {
423 final String s = file.toUri().toString();
424 final String b = basePath.toUri().toString();
425 final String id = s.replace(b, StringUtil.EMPTY).replace("." + imageExtention, StringUtil.EMPTY).replace("/", StringUtil.EMPTY);
426 if (logger.isDebugEnabled()) {
427 logger.debug("Base: {} File: {} DocId: {}", b, s, id);
428 }
429 return id;
430 }
431
432 public long getCount() {
433 if (!deletedFileList.isEmpty()) {
434 deleteFiles();
435 }
436 return count;
437 }
438
439 @Override
440 public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException {
441 return FileVisitResult.CONTINUE;
442 }
443
444 @Override
445 public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
446 if (System.currentTimeMillis() - Files.getLastModifiedTime(file).toMillis() > expiry) {
447 deletedFileList.add(file);
448 if (deletedFileList.size() > maxPurgeSize) {
449 deleteFiles();
450 }
451 }
452 return FileVisitResult.CONTINUE;
453 }
454
455 @Override
456 public FileVisitResult visitFileFailed(final Path file, final IOException e) throws IOException {
457 if (e != null) {
458 logger.warn("I/O exception on {}", file, e);
459 }
460 return FileVisitResult.CONTINUE;
461 }
462
463 @Override
464 public FileVisitResult postVisitDirectory(final Path dir, final IOException e) throws IOException {
465 if (e != null) {
466 logger.warn("I/O exception on {}", dir, e);
467 }
468 deleteEmptyDirectory(dir);
469 return FileVisitResult.CONTINUE;
470 }
471
472 private boolean deleteEmptyDirectory(final Path dir) throws IOException {
473 if (dir == null) {
474 return false;
475 }
476 final File directory = dir.toFile();
477 if (directory.list() != null && directory.list().length == 0 && !THUMBNAILS_DIR_NAME.equals(directory.getName())) {
478 Files.delete(dir);
479 if (logger.isDebugEnabled()) {
480 logger.debug("Delete {}", dir);
481 }
482 return true;
483 }
484 return false;
485 }
486
487 }
488
489 public void migrate() {
490 new Thread(() -> {
491 final Path basePath = baseDir.toPath();
492 final String suffix = "." + imageExtention;
493 try (Stream<Path> paths = Files.walk(basePath)) {
494 paths.filter(path -> path.toFile().getName().endsWith(imageExtention)).forEach(path -> {
495 final Path subPath = basePath.relativize(path);
496 final String docId = subPath.toString().replace("/", StringUtil.EMPTY).replace(suffix, StringUtil.EMPTY);
497 final String filename = getImageFilename(docId);
498 final Path newPath = basePath.resolve(filename);
499 if (!path.equals(newPath)) {
500 try {
501 try {
502 Files.createDirectories(newPath.getParent());
503 } catch (final FileAlreadyExistsException e) {
504
505 }
506 Files.move(path, newPath);
507 logger.info("Move {} to {}", path, newPath);
508 } catch (final IOException e) {
509 logger.warn("Failed to move {}", path, e);
510 }
511 }
512 });
513 } catch (final IOException e) {
514 logger.warn("Failed to migrate thumbnail images.", e);
515 }
516 }, "ThumbnailMigrator").start();
517 }
518
519 public void setThumbnailPathCacheSize(final int thumbnailPathCacheSize) {
520 this.thumbnailPathCacheSize = thumbnailPathCacheSize;
521 }
522
523 public void setImageExtention(final String imageExtention) {
524 this.imageExtention = imageExtention;
525 }
526
527 public void setSplitSize(final int splitSize) {
528 this.splitSize = splitSize;
529 }
530
531 public void setThumbnailTaskQueueSize(final int thumbnailTaskQueueSize) {
532 this.thumbnailTaskQueueSize = thumbnailTaskQueueSize;
533 }
534
535 public void setNoImageExpired(final long noImageExpired) {
536 this.noImageExpired = noImageExpired;
537 }
538
539 }