Skip to content

Commit c3af19a

Browse files
authored
Fix: Kobo sync missing book covers (v1.16.4) (#2147)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
1 parent 2a89b19 commit c3af19a

13 files changed

+601
-324
lines changed
Lines changed: 10 additions & 311 deletions
Original file line numberDiff line numberDiff line change
@@ -1,338 +1,37 @@
11
package com.adityachandel.booklore.service.migration;
22

3-
import com.adityachandel.booklore.config.AppProperties;
43
import com.adityachandel.booklore.model.entity.AppMigrationEntity;
5-
import com.adityachandel.booklore.model.entity.AppSettingEntity;
6-
import com.adityachandel.booklore.model.entity.BookEntity;
7-
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
84
import com.adityachandel.booklore.repository.AppMigrationRepository;
9-
import com.adityachandel.booklore.repository.AppSettingsRepository;
10-
import com.adityachandel.booklore.repository.BookRepository;
11-
import com.adityachandel.booklore.service.book.BookQueryService;
12-
import com.adityachandel.booklore.service.file.FileFingerprint;
13-
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
14-
import com.adityachandel.booklore.service.InstallationService;
15-
import com.adityachandel.booklore.util.BookUtils;
16-
import com.adityachandel.booklore.util.FileService;
17-
import com.adityachandel.booklore.util.FileUtils;
18-
import com.fasterxml.jackson.databind.ObjectMapper;
195
import jakarta.transaction.Transactional;
206
import lombok.AllArgsConstructor;
217
import lombok.extern.slf4j.Slf4j;
22-
import org.springframework.core.io.Resource;
23-
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
248
import org.springframework.stereotype.Service;
259

26-
import javax.imageio.ImageIO;
27-
import java.awt.image.BufferedImage;
28-
import java.io.File;
29-
import java.io.IOException;
30-
import java.io.UncheckedIOException;
31-
import java.nio.file.Files;
32-
import java.nio.file.Path;
33-
import java.nio.file.Paths;
34-
import java.nio.file.StandardCopyOption;
35-
import java.time.Instant;
3610
import java.time.LocalDateTime;
37-
import java.util.Comparator;
38-
import java.util.List;
3911

4012
@Slf4j
4113
@AllArgsConstructor
4214
@Service
4315
public class AppMigrationService {
4416

45-
private static final String INSTALLATION_ID_KEY = "installation_id";
46-
4717
private AppMigrationRepository migrationRepository;
48-
private AppSettingsRepository appSettingsRepository;
49-
private BookRepository bookRepository;
50-
private BookQueryService bookQueryService;
51-
private MetadataMatchService metadataMatchService;
52-
private AppProperties appProperties;
53-
private FileService fileService;
54-
private ObjectMapper objectMapper;
55-
private InstallationService installationService;
56-
57-
@Transactional
58-
public void generateInstallationId() {
59-
if (migrationRepository.existsById("generateInstallationId")) return;
60-
61-
installationService.getOrCreateInstallation();
62-
63-
migrationRepository.save(new AppMigrationEntity("generateInstallationId", LocalDateTime.now(), "Generate unique installation ID using timestamp and UUID"));
64-
}
65-
66-
@Transactional
67-
public void populateSearchTextOnce() {
68-
if (migrationRepository.existsById("populateSearchText")) return;
69-
70-
int batchSize = 1000;
71-
int processedCount = 0;
72-
int offset = 0;
7318

74-
while (true) {
75-
List<BookEntity> bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
76-
if (bookBatch.isEmpty()) break;
77-
78-
List<Long> bookIds = bookBatch.stream().map(BookEntity::getId).toList();
79-
List<BookEntity> books = bookRepository.findBooksWithMetadataAndAuthors(bookIds);
80-
81-
for (BookEntity book : books) {
82-
BookMetadataEntity m = book.getMetadata();
83-
if (m != null) {
84-
try {
85-
m.setSearchText(BookUtils.buildSearchText(m));
86-
} catch (Exception ex) {
87-
log.warn("Failed to build search text for book {}: {}", book.getId(), ex.getMessage());
88-
}
89-
}
90-
}
91-
92-
bookRepository.saveAll(books);
93-
processedCount += books.size();
94-
offset += batchSize;
95-
96-
log.info("Migration progress: {} books processed", processedCount);
97-
98-
if (bookBatch.size() < batchSize) break;
99-
}
100-
101-
log.info("Migration 'populateSearchText' completed. Total books processed: {}", processedCount);
102-
migrationRepository.save(new AppMigrationEntity(
103-
"populateSearchText",
104-
LocalDateTime.now(),
105-
"Populate search_text column for all books"
106-
));
107-
}
10819

10920
@Transactional
110-
public void populateMissingFileSizesOnce() {
111-
if (migrationRepository.existsById("populateFileSizes")) {
21+
public void executeMigration(Migration migration) {
22+
if (migrationRepository.existsById(migration.getKey())) {
23+
log.debug("Migration '{}' already executed, skipping", migration.getKey());
11224
return;
11325
}
114-
115-
List<BookEntity> books = bookRepository.findAllWithMetadataByFileSizeKbIsNull();
116-
for (BookEntity book : books) {
117-
Long sizeInKb = FileUtils.getFileSizeInKb(book);
118-
if (sizeInKb != null) {
119-
book.setFileSizeKb(sizeInKb);
120-
}
121-
}
122-
bookRepository.saveAll(books);
123-
124-
log.info("Starting migration 'populateFileSizes' for {} books.", books.size());
125-
AppMigrationEntity migration = new AppMigrationEntity();
126-
migration.setKey("populateFileSizes");
127-
migration.setExecutedAt(LocalDateTime.now());
128-
migration.setDescription("Populate file size for existing books");
129-
migrationRepository.save(migration);
130-
log.info("Migration 'populateFileSizes' executed successfully.");
131-
}
132-
133-
@Transactional
134-
public void populateMetadataScoresOnce() {
135-
if (migrationRepository.existsById("populateMetadataScores_v2")) return;
136-
137-
List<BookEntity> books = bookQueryService.getAllFullBookEntities();
138-
for (BookEntity book : books) {
139-
Float score = metadataMatchService.calculateMatchScore(book);
140-
book.setMetadataMatchScore(score);
141-
}
142-
bookRepository.saveAll(books);
143-
144-
log.info("Migration 'populateMetadataScores_v2' applied to {} books.", books.size());
145-
migrationRepository.save(new AppMigrationEntity("populateMetadataScores_v2", LocalDateTime.now(), "Calculate and store metadata match score for all books"));
146-
}
147-
148-
@Transactional
149-
public void populateFileHashesOnce() {
150-
if (migrationRepository.existsById("populateFileHashesV2")) return;
151-
152-
List<BookEntity> books = bookRepository.findAll();
153-
int updated = 0;
154-
155-
for (BookEntity book : books) {
156-
Path path = book.getFullFilePath();
157-
if (path == null || !Files.exists(path)) {
158-
log.warn("Skipping hashing for book ID {} — file not found at path: {}", book.getId(), path);
159-
continue;
160-
}
161-
162-
try {
163-
String hash = FileFingerprint.generateHash(path);
164-
if (book.getInitialHash() == null) {
165-
book.setInitialHash(hash);
166-
}
167-
book.setCurrentHash(hash);
168-
updated++;
169-
} catch (Exception e) {
170-
log.error("Failed to compute hash for file: {}", path, e);
171-
}
172-
}
173-
174-
bookRepository.saveAll(books);
175-
176-
log.info("Migration 'populateFileHashesV2' applied to {} books.", updated);
177-
migrationRepository.save(new AppMigrationEntity(
178-
"populateFileHashesV2",
179-
LocalDateTime.now(),
180-
"Calculate and store initialHash and currentHash for all books"
181-
));
182-
}
183-
184-
@Transactional
185-
public void populateCoversAndResizeThumbnails() {
186-
if (migrationRepository.existsById("populateCoversAndResizeThumbnails")) return;
187-
188-
long start = System.nanoTime();
189-
log.info("Starting migration: populateCoversAndResizeThumbnails");
190-
191-
String dataFolder = appProperties.getPathConfig();
192-
Path thumbsDir = Paths.get(dataFolder, "thumbs");
193-
Path imagesDir = Paths.get(dataFolder, "images");
194-
195-
try {
196-
if (Files.exists(thumbsDir)) {
197-
try (var stream = Files.walk(thumbsDir)) {
198-
stream.filter(Files::isRegularFile)
199-
.forEach(path -> {
200-
BufferedImage originalImage = null;
201-
BufferedImage resized = null;
202-
try {
203-
// Load original image
204-
originalImage = ImageIO.read(path.toFile());
205-
if (originalImage == null) {
206-
log.warn("Skipping non-image file: {}", path);
207-
return;
208-
}
209-
210-
// Extract bookId from folder structure
211-
Path relative = thumbsDir.relativize(path); // e.g., "11/f.jpg"
212-
String bookId = relative.getParent().toString(); // "11"
213-
214-
Path bookDir = imagesDir.resolve(bookId);
215-
Files.createDirectories(bookDir);
216-
217-
// Copy original to cover.jpg
218-
Path coverFile = bookDir.resolve("cover.jpg");
219-
ImageIO.write(originalImage, "jpg", coverFile.toFile());
220-
221-
// Resize and save thumbnail.jpg
222-
resized = FileService.resizeImage(originalImage, 250, 350);
223-
Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
224-
ImageIO.write(resized, "jpg", thumbnailFile.toFile());
225-
226-
log.debug("Processed book {}: cover={} thumbnail={}", bookId, coverFile, thumbnailFile);
227-
} catch (IOException e) {
228-
log.error("Error processing file {}", path, e);
229-
throw new UncheckedIOException(e);
230-
} finally {
231-
if (originalImage != null) {
232-
originalImage.flush();
233-
}
234-
if (resized != null) {
235-
resized.flush();
236-
}
237-
}
238-
});
239-
}
240-
241-
// Delete old thumbs directory
242-
log.info("Deleting old thumbs directory: {}", thumbsDir);
243-
try (var stream = Files.walk(thumbsDir)) {
244-
stream.sorted(Comparator.reverseOrder())
245-
.map(Path::toFile)
246-
.forEach(File::delete);
247-
}
248-
}
249-
} catch (IOException e) {
250-
log.error("Error during migration populateCoversAndResizeThumbnails", e);
251-
throw new UncheckedIOException(e);
252-
}
253-
254-
migrationRepository.save(new AppMigrationEntity(
255-
"populateCoversAndResizeThumbnails",
256-
LocalDateTime.now(),
257-
"Copy thumbnails to images/{bookId}/cover.jpg and create resized 250x350 images as thumbnail.jpg"
258-
));
259-
260-
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
261-
log.info("Completed migration: populateCoversAndResizeThumbnails in {} ms", elapsedMs);
262-
}
263-
264-
@Transactional
265-
public void moveIconsToDataFolder() {
266-
if (migrationRepository.existsById("moveIconsToDataFolder")) return;
267-
268-
long start = System.nanoTime();
269-
log.info("Starting migration: moveIconsToDataFolder");
270-
27126
try {
272-
String targetFolder = fileService.getIconsSvgFolder();
273-
Path targetDir = Paths.get(targetFolder);
274-
Files.createDirectories(targetDir);
275-
276-
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
277-
Resource[] resources = resolver.getResources("classpath:static/images/icons/svg/*.svg");
278-
279-
int copiedCount = 0;
280-
for (Resource resource : resources) {
281-
String filename = resource.getFilename();
282-
if (filename == null) continue;
283-
284-
Path targetFile = targetDir.resolve(filename);
285-
286-
try (var inputStream = resource.getInputStream()) {
287-
Files.copy(inputStream, targetFile, StandardCopyOption.REPLACE_EXISTING);
288-
copiedCount++;
289-
log.debug("Copied icon: {} to {}", filename, targetFile);
290-
} catch (IOException e) {
291-
log.error("Failed to copy icon: {}", filename, e);
292-
}
293-
}
294-
295-
log.info("Copied {} SVG icons from resources to data folder", copiedCount);
296-
297-
migrationRepository.save(new AppMigrationEntity(
298-
"moveIconsToDataFolder",
299-
LocalDateTime.now(),
300-
"Move SVG icons from resources/static/images/icons/svg to data/icons/svg"
301-
));
27+
migration.execute();
28+
AppMigrationEntity entity = new AppMigrationEntity(migration.getKey(), LocalDateTime.now(), migration.getDescription());
29+
migrationRepository.save(entity);
30230

303-
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
304-
log.info("Completed migration: moveIconsToDataFolder in {} ms", elapsedMs);
305-
} catch (IOException e) {
306-
log.error("Error during migration moveIconsToDataFolder", e);
307-
throw new UncheckedIOException(e);
31+
log.info("Migration '{}' completed successfully", migration.getKey());
32+
} catch (Exception e) {
33+
log.error("Migration '{}' failed", migration.getKey(), e);
34+
throw e;
30835
}
30936
}
310-
311-
@Transactional
312-
public void migrateInstallationIdToJson() {
313-
if (migrationRepository.existsById("migrateInstallationIdToJson")) return;
314-
315-
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
316-
317-
if (setting != null) {
318-
String value = setting.getVal();
319-
try {
320-
objectMapper.readTree(value);
321-
log.info("Installation ID is already in JSON format, skipping migration");
322-
} catch (Exception e) {
323-
Instant now = Instant.now();
324-
String json = String.format("{\"id\":\"%s\",\"date\":\"%s\"}", value, now);
325-
setting.setVal(json);
326-
appSettingsRepository.save(setting);
327-
log.info("Migrated installation ID to JSON format with current date");
328-
}
329-
}
330-
331-
migrationRepository.save(new AppMigrationEntity(
332-
"migrateInstallationIdToJson",
333-
LocalDateTime.now(),
334-
"Migrate existing installation_id from plain string to JSON format with date"
335-
));
336-
}
337-
33837
}
Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.adityachandel.booklore.service.migration;
22

3+
import com.adityachandel.booklore.service.migration.migrations.*;
34
import lombok.AllArgsConstructor;
45
import org.springframework.boot.context.event.ApplicationReadyEvent;
56
import org.springframework.context.event.EventListener;
@@ -10,16 +11,26 @@
1011
public class AppMigrationStartup {
1112

1213
private final AppMigrationService appMigrationService;
14+
private final GenerateInstallationIdMigration generateInstallationIdMigration;
15+
private final MigrateInstallationIdToJsonMigration migrateInstallationIdToJsonMigration;
16+
private final PopulateMissingFileSizesMigration populateMissingFileSizesMigration;
17+
private final PopulateMetadataScoresMigration populateMetadataScoresMigration;
18+
private final PopulateFileHashesMigration populateFileHashesMigration;
19+
private final PopulateCoversAndResizeThumbnailsMigration populateCoversAndResizeThumbnailsMigration;
20+
private final PopulateSearchTextMigration populateSearchTextMigration;
21+
private final MoveIconsToDataFolderMigration moveIconsToDataFolderMigration;
22+
private final GenerateCoverHashMigration generateCoverHashMigration;
1323

1424
@EventListener(ApplicationReadyEvent.class)
1525
public void runMigrationsOnce() {
16-
appMigrationService.generateInstallationId();
17-
appMigrationService.migrateInstallationIdToJson();
18-
appMigrationService.populateMissingFileSizesOnce();
19-
appMigrationService.populateMetadataScoresOnce();
20-
appMigrationService.populateFileHashesOnce();
21-
appMigrationService.populateCoversAndResizeThumbnails();
22-
appMigrationService.populateSearchTextOnce();
23-
appMigrationService.moveIconsToDataFolder();
26+
appMigrationService.executeMigration(generateInstallationIdMigration);
27+
appMigrationService.executeMigration(migrateInstallationIdToJsonMigration);
28+
appMigrationService.executeMigration(populateMissingFileSizesMigration);
29+
appMigrationService.executeMigration(populateMetadataScoresMigration);
30+
appMigrationService.executeMigration(populateFileHashesMigration);
31+
appMigrationService.executeMigration(populateCoversAndResizeThumbnailsMigration);
32+
appMigrationService.executeMigration(populateSearchTextMigration);
33+
appMigrationService.executeMigration(moveIconsToDataFolderMigration);
34+
appMigrationService.executeMigration(generateCoverHashMigration);
2435
}
2536
}

0 commit comments

Comments
 (0)