|
1 | 1 | package com.adityachandel.booklore.service.migration; |
2 | 2 |
|
3 | | -import com.adityachandel.booklore.config.AppProperties; |
4 | 3 | 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; |
8 | 4 | 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; |
19 | 5 | import jakarta.transaction.Transactional; |
20 | 6 | import lombok.AllArgsConstructor; |
21 | 7 | import lombok.extern.slf4j.Slf4j; |
22 | | -import org.springframework.core.io.Resource; |
23 | | -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; |
24 | 8 | import org.springframework.stereotype.Service; |
25 | 9 |
|
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; |
36 | 10 | import java.time.LocalDateTime; |
37 | | -import java.util.Comparator; |
38 | | -import java.util.List; |
39 | 11 |
|
40 | 12 | @Slf4j |
41 | 13 | @AllArgsConstructor |
42 | 14 | @Service |
43 | 15 | public class AppMigrationService { |
44 | 16 |
|
45 | | - private static final String INSTALLATION_ID_KEY = "installation_id"; |
46 | | - |
47 | 17 | 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; |
73 | 18 |
|
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 | | - } |
108 | 19 |
|
109 | 20 | @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()); |
112 | 24 | return; |
113 | 25 | } |
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 | | - |
271 | 26 | 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); |
302 | 30 |
|
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; |
308 | 35 | } |
309 | 36 | } |
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 | | - |
338 | 37 | } |
0 commit comments