package com.magamochi.ingestion.providers.impl; import static org.apache.commons.lang3.StringUtils.isBlank; import com.google.common.util.concurrent.RateLimiter; import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.common.ContentProviders; import com.magamochi.ingestion.client.RoliaScanClient; import com.magamochi.ingestion.client.RoliaScanClient.RoliaScanMangaRequest; import com.magamochi.ingestion.model.dto.ContentImageInfoDTO; import com.magamochi.ingestion.model.dto.ContentInfoDTO; import com.magamochi.ingestion.model.dto.MangaInfoDTO; import com.magamochi.ingestion.providers.ContentProvider; import com.magamochi.ingestion.providers.PagedContentProvider; import com.magamochi.ingestion.service.FlareService; import com.magamochi.ingestion.service.RoliaScanAuthService; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Service; @Log4j2 @Service(ContentProviders.ROLIA_SCAN) @RequiredArgsConstructor public class RoliaScanProvider implements ContentProvider, PagedContentProvider { private final FlareService flareService; private final RoliaScanClient roliaScanClient; private final RoliaScanAuthService roliaScanAuthService; private final RateLimiter roliaScanRateLimiter; @Override public List getAvailableChapters(MangaContentProvider provider) { log.info( "Getting available chapters from {}, manga {}", ContentProviders.ROLIA_SCAN, provider.getManga().getTitle()); try { var mangaId = extractMangaId(provider.getUrl()); log.info("Extracted manga id {} from {}", mangaId, provider.getUrl()); var auth = roliaScanAuthService.generateToken(); return fetchAllChapters(mangaId, auth.token(), auth.timestamp()); } catch (NoSuchElementException e) { log.error("Error parsing chapters from Rolia Scan", e); return List.of(); } catch (Exception e) { log.error("Error fetching chapters from Rolia Scan API", e); return List.of(); } } private String extractMangaId(String mangaUrl) { var document = flareService.getContentAsJsoupDocument(mangaUrl, ContentProviders.ROLIA_SCAN); var mangaId = document.body().attr("data-manga-id"); if (isBlank(mangaId)) { throw new NoSuchElementException("data-manga-id not found in page body"); } return mangaId; } private List fetchAllChapters(String mangaId, String token, long timestamp) { log.info("Fetching all chapters for manga {}", mangaId); var allChapters = new ArrayList(); int offset = 0; boolean hasMore = true; int page = 0; while (hasMore) { page++; log.info("Fetching chapters for manga {}, page {}, offset {}", mangaId, page, offset); roliaScanRateLimiter.acquire(); var response = roliaScanClient.getChapters(mangaId, offset, 500, "DESC", token, timestamp); log.info( "Page {} fetched {} chapters for manga {} (offset {}, total {}, hasMore {})", page, response.chapters().size(), mangaId, offset, response.total(), response.hasMore()); response.chapters().stream().map(this::toContentInfo).forEach(allChapters::add); hasMore = response.hasMore(); offset += 500; } log.info( "Fetched total {} chapters for manga {} across {} pages", allChapters.size(), mangaId, page); return allChapters; } private ContentInfoDTO toContentInfo(RoliaScanClient.RoliaScanChapterItem chapter) { var title = isBlank(chapter.title()) || "N/A".equals(chapter.title()) ? "Ch. " + chapter.chapter() : chapter.chapter() + " - " + chapter.title(); return new ContentInfoDTO(title, chapter.url(), mapLanguage(chapter.language())); } private static String mapLanguage(String language) { return switch (language) { case "en" -> "en-US"; case "pt" -> "pt-BR"; default -> language; }; } @Override public List getContentImages(String chapterUrl) { log.info("Getting content images from {}, url {}", ContentProviders.ROLIA_SCAN, chapterUrl); try { var chapterId = extractChapterId(chapterUrl); log.info("Extracted chapter id {} from url {}", chapterId, chapterUrl); roliaScanRateLimiter.acquire(); var response = roliaScanClient.getChapterContent(chapterId); log.info("Fetched {} images for chapter {}", response.images().size(), chapterId); return IntStream.range(0, response.images().size()) .boxed() .map(position -> new ContentImageInfoDTO(position, response.images().get(position))) .toList(); } catch (Exception e) { log.error("Error fetching chapter images from Rolia Scan API, url {}", chapterUrl, e); return List.of(); } } private String extractChapterId(String chapterUrl) { var lastDash = chapterUrl.lastIndexOf('-'); if (lastDash < 0) { throw new NoSuchElementException("Invalid chapter URL format: " + chapterUrl); } return chapterUrl.substring(lastDash + 1); } @Override public int getTotalPages() { return 1; } @Override public List getMangasFromPage(int page) { log.info("Getting mangas from {}, starting at page {}", ContentProviders.ROLIA_SCAN, page); try { var allMangas = new ArrayList(); int currentPage = page; while (true) { log.info("Fetching mangas from {}, page {}", ContentProviders.ROLIA_SCAN, currentPage); roliaScanRateLimiter.acquire(); var items = roliaScanClient.loadMangas(new RoliaScanMangaRequest(currentPage)); var filteredItems = items.stream().filter(item -> !"Novel".equalsIgnoreCase(item.type())).toList(); log.info( "Page {} returned {} mangas ({} novels filtered out)", currentPage, filteredItems.size(), items.size() - filteredItems.size()); if (filteredItems.isEmpty()) { log.info("No more mangas on page {}, stopping pagination", currentPage); break; } filteredItems.stream() .map(item -> new MangaInfoDTO(item.title(), item.url())) .forEach(allMangas::add); currentPage++; } log.info( "Loaded {} mangas from {} across {} pages", allMangas.size(), ContentProviders.ROLIA_SCAN, currentPage - page); return allMangas; } catch (Exception e) { log.error("Error loading mangas from Rolia Scan API, page {}", page, e); return List.of(); } } }