mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-06 03:59:50 -06:00
Feat/lubimyczytac metadata provider (#2019)
* feat: add LubimyCzytac metadata provider - Add LubimyCzytac parser with web scraping for lubimyczytac.pl - Extract book metadata including title, authors, description, ratings - Parse JSON-LD structured data for reliable metadata extraction - Add database migration with columns and JSON property name updates - Add comprehensive test coverage for parser * feat: add LubimyCzytac UI integration - Add LC ID and LC Rating fields to metadata picker - Update settings page with Lubimyczytac provider toggle - Add LubimyCzytac to metadata searcher provider list - Display Lubimyczytac in all metadata viewer sections - Add Lubimyczytac to advanced fetch options - Update TypeScript models to match backend serialization
This commit is contained in:
@@ -12,14 +12,15 @@ public class BookParserConfig {
|
||||
|
||||
@Bean
|
||||
public Map<MetadataProvider, BookParser> parserMap(GoogleParser googleParser, AmazonBookParser amazonBookParser,
|
||||
GoodReadsParser goodReadsParser, HardcoverParser hardcoverParser, ComicvineBookParser comicvineBookParser, DoubanBookParser doubanBookParser) {
|
||||
GoodReadsParser goodReadsParser, HardcoverParser hardcoverParser, ComicvineBookParser comicvineBookParser, DoubanBookParser doubanBookParser, LubimyCzytacParser lubimyczytacParser) {
|
||||
return Map.of(
|
||||
MetadataProvider.Amazon, amazonBookParser,
|
||||
MetadataProvider.GoodReads, goodReadsParser,
|
||||
MetadataProvider.Google, googleParser,
|
||||
MetadataProvider.Hardcover, hardcoverParser,
|
||||
MetadataProvider.Comicvine, comicvineBookParser,
|
||||
MetadataProvider.Douban, doubanBookParser
|
||||
MetadataProvider.Douban, doubanBookParser,
|
||||
MetadataProvider.Lubimyczytac, lubimyczytacParser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ public class MetadataClearFlags {
|
||||
private boolean goodreadsReviewCount;
|
||||
private boolean hardcoverRating;
|
||||
private boolean hardcoverReviewCount;
|
||||
private boolean lubimyczytacId;
|
||||
private boolean lubimyczytacRating;
|
||||
private boolean authors;
|
||||
private boolean categories;
|
||||
private boolean moods;
|
||||
|
||||
@@ -43,7 +43,9 @@ public class BookMetadata {
|
||||
private String doubanId;
|
||||
private Double doubanRating;
|
||||
private Integer doubanReviewCount;
|
||||
private Double lubimyczytacRating;
|
||||
private String googleId;
|
||||
private String lubimyczytacId;
|
||||
private Instant coverUpdatedOn;
|
||||
private Set<String> authors;
|
||||
private Set<String> categories;
|
||||
@@ -80,6 +82,8 @@ public class BookMetadata {
|
||||
private Boolean hardcoverReviewCountLocked;
|
||||
private Boolean doubanRatingLocked;
|
||||
private Boolean doubanReviewCountLocked;
|
||||
private Boolean lubimyczytacIdLocked;
|
||||
private Boolean lubimyczytacRatingLocked;
|
||||
private Boolean coverLocked;
|
||||
private Boolean authorsLocked;
|
||||
private Boolean categoriesLocked;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.adityachandel.booklore.model.dto.settings;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -10,6 +11,8 @@ public class MetadataProviderSettings {
|
||||
private Hardcover hardcover;
|
||||
private Comicvine comicvine;
|
||||
private Douban douban;
|
||||
@JsonProperty("lubimyczytac")
|
||||
private Lubimyczytac lubimyczytac;
|
||||
|
||||
@Data
|
||||
public static class Amazon {
|
||||
@@ -45,4 +48,9 @@ public class MetadataProviderSettings {
|
||||
public static class Douban {
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Lubimyczytac {
|
||||
private boolean enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ public class BookMetadataEntity {
|
||||
@Column(name = "comicvine_id", length = 100)
|
||||
private String comicvineId;
|
||||
|
||||
@Column(name = "lubimyczytac_id", length = 100)
|
||||
private String lubimyczytacId;
|
||||
|
||||
@Column(name = "lubimyczytac_rating")
|
||||
private Double lubimyczytacRating;
|
||||
|
||||
@Column(name = "title_locked")
|
||||
@Builder.Default
|
||||
@@ -223,6 +228,14 @@ public class BookMetadataEntity {
|
||||
@Builder.Default
|
||||
private Boolean comicvineIdLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "lubimyczytac_id_locked")
|
||||
@Builder.Default
|
||||
private Boolean lubimyczytacIdLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "lubimyczytac_rating_locked")
|
||||
@Builder.Default
|
||||
private Boolean lubimyczytacRatingLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "reviews_locked")
|
||||
@Builder.Default
|
||||
private Boolean reviewsLocked = Boolean.FALSE;
|
||||
@@ -313,11 +326,13 @@ public class BookMetadataEntity {
|
||||
this.goodreadsReviewCountLocked = lock;
|
||||
this.hardcoverRatingLocked = lock;
|
||||
this.hardcoverReviewCountLocked = lock;
|
||||
this.lubimyczytacRatingLocked = lock;
|
||||
this.comicvineIdLocked = lock;
|
||||
this.goodreadsIdLocked = lock;
|
||||
this.hardcoverIdLocked = lock;
|
||||
this.hardcoverBookIdLocked = lock;
|
||||
this.googleIdLocked = lock;
|
||||
this.lubimyczytacIdLocked = lock;
|
||||
this.reviewsLocked = lock;
|
||||
}
|
||||
|
||||
@@ -346,11 +361,13 @@ public class BookMetadataEntity {
|
||||
&& Boolean.TRUE.equals(this.goodreadsReviewCountLocked)
|
||||
&& Boolean.TRUE.equals(this.hardcoverRatingLocked)
|
||||
&& Boolean.TRUE.equals(this.hardcoverReviewCountLocked)
|
||||
&& Boolean.TRUE.equals(this.lubimyczytacRatingLocked)
|
||||
&& Boolean.TRUE.equals(this.goodreadsIdLocked)
|
||||
&& Boolean.TRUE.equals(this.comicvineIdLocked)
|
||||
&& Boolean.TRUE.equals(this.hardcoverIdLocked)
|
||||
&& Boolean.TRUE.equals(this.hardcoverBookIdLocked)
|
||||
&& Boolean.TRUE.equals(this.googleIdLocked)
|
||||
&& Boolean.TRUE.equals(this.lubimyczytacIdLocked)
|
||||
&& Boolean.TRUE.equals(this.reviewsLocked)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum MetadataProvider {
|
||||
Amazon, GoodReads, Google, Hardcover, Comicvine, Douban
|
||||
Amazon, GoodReads, Google, Hardcover, Comicvine, Douban, Lubimyczytac
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ public class BookMetadataUpdater {
|
||||
handleFieldUpdate(e.getGoodreadsReviewCountLocked(), clear.isGoodreadsReviewCount(), m.getGoodreadsReviewCount(), e::setGoodreadsReviewCount, e::getGoodreadsReviewCount, replaceMode);
|
||||
handleFieldUpdate(e.getHardcoverRatingLocked(), clear.isHardcoverRating(), m.getHardcoverRating(), e::setHardcoverRating, e::getHardcoverRating, replaceMode);
|
||||
handleFieldUpdate(e.getHardcoverReviewCountLocked(), clear.isHardcoverReviewCount(), m.getHardcoverReviewCount(), e::setHardcoverReviewCount, e::getHardcoverReviewCount, replaceMode);
|
||||
handleFieldUpdate(e.getLubimyczytacIdLocked(), clear.isLubimyczytacId(), m.getLubimyczytacId(), v -> e.setLubimyczytacId(nullIfBlank(v)), e::getLubimyczytacId, replaceMode);
|
||||
handleFieldUpdate(e.getLubimyczytacRatingLocked(), clear.isLubimyczytacRating(), m.getLubimyczytacRating(), e::setLubimyczytacRating, e::getLubimyczytacRating, replaceMode);
|
||||
}
|
||||
|
||||
private <T> void handleFieldUpdate(Boolean locked, boolean shouldClear, T newValue, Consumer<T> setter, Supplier<T> getter, MetadataReplaceMode mode) {
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package com.adityachandel.booklore.service.metadata.parser;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class LubimyCzytacParser implements BookParser {
|
||||
|
||||
private static final String BASE_URL = "https://lubimyczytac.pl";
|
||||
private static final String SEARCH_URL = BASE_URL + "/szukaj/ksiazki";
|
||||
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
||||
private static final int CONNECTION_TIMEOUT_MS = 10000;
|
||||
private static final int MAX_RESULTS = 10;
|
||||
private static final double RATING_SCALE_DIVISOR = 2.0; // Convert 10-point scale to 5-point scale
|
||||
private static final Pattern SERIES_NUMBER_PATTERN = Pattern.compile("\\(tom\\s+(\\d+)\\)");
|
||||
private static final Pattern BOOK_ID_PATTERN = Pattern.compile("/ksiazka/(\\d+)");
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
log.info("Fetching LubimyCzytac metadata for book: {}", book.getTitle());
|
||||
|
||||
// Check if provider is enabled
|
||||
var settings = appSettingService.getAppSettings().getMetadataProviderSettings();
|
||||
if (settings == null || settings.getLubimyczytac() == null || !settings.getLubimyczytac().isEnabled()) {
|
||||
log.info("LubimyCzytac provider is disabled");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
try {
|
||||
// Build search query
|
||||
String query = buildSearchQuery(fetchMetadataRequest);
|
||||
if (query.isEmpty()) {
|
||||
log.warn("Empty search query for book: {}", book.getTitle());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
String author = fetchMetadataRequest.getAuthor();
|
||||
String searchIsbn = fetchMetadataRequest.getIsbn();
|
||||
boolean searchingByIsbn = searchIsbn != null && !searchIsbn.isEmpty();
|
||||
|
||||
// Search for books
|
||||
List<String> bookUrls = searchBooks(query, author);
|
||||
if (bookUrls.isEmpty()) {
|
||||
log.info("No results found for query: {}", query);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// Parse details for each book
|
||||
List<BookMetadata> results = new ArrayList<>();
|
||||
for (String url : bookUrls) {
|
||||
BookMetadata metadata = parseBookDetails(url);
|
||||
if (metadata != null) {
|
||||
// If searching by ISBN, validate that the book's ISBN matches
|
||||
if (searchingByIsbn) {
|
||||
if (isbnMatches(metadata, searchIsbn)) {
|
||||
results.add(metadata);
|
||||
} else {
|
||||
log.debug("Skipping book {} - ISBN doesn't match search ISBN {}",
|
||||
metadata.getTitle(), searchIsbn);
|
||||
}
|
||||
} else {
|
||||
results.add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit results to avoid excessive scraping
|
||||
if (results.size() >= MAX_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Found {} LubimyCzytac results for query: {}", results.size(), query);
|
||||
return results;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching LubimyCzytac metadata", e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
List<BookMetadata> results = fetchMetadata(book, fetchMetadataRequest);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
private String buildSearchQuery(FetchMetadataRequest request) {
|
||||
String isbn = request.getIsbn();
|
||||
if (isbn != null && !isbn.isEmpty()) {
|
||||
return isbn;
|
||||
}
|
||||
|
||||
String title = request.getTitle();
|
||||
if (title != null && !title.isEmpty()) {
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private String buildSearchUrl(String query, String author) {
|
||||
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String url = SEARCH_URL + "?phrase=" + encodedQuery;
|
||||
|
||||
if (author != null && !author.isEmpty()) {
|
||||
String encodedAuthor = URLEncoder.encode(author, StandardCharsets.UTF_8);
|
||||
url += "&author=" + encodedAuthor;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private List<String> searchBooks(String query, String author) {
|
||||
List<String> bookUrls = new ArrayList<>();
|
||||
|
||||
try {
|
||||
String searchUrl = buildSearchUrl(query, author);
|
||||
log.info("Searching LubimyCzytac: {}", searchUrl);
|
||||
|
||||
Document doc = Jsoup.connect(searchUrl)
|
||||
.userAgent(USER_AGENT)
|
||||
.timeout(CONNECTION_TIMEOUT_MS)
|
||||
.get();
|
||||
|
||||
Elements results = doc.select(".authorAllBooks__single");
|
||||
log.info("Found {} search results", results.size());
|
||||
|
||||
for (Element result : results) {
|
||||
Element titleLink = result.selectFirst(".authorAllBooks__singleTextTitle");
|
||||
if (titleLink != null) {
|
||||
String href = titleLink.attr("href");
|
||||
if (href != null && !href.isEmpty()) {
|
||||
String fullUrl = href.startsWith("http") ? href : BASE_URL + href;
|
||||
bookUrls.add(fullUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Error searching LubimyCzytac", e);
|
||||
}
|
||||
|
||||
return bookUrls;
|
||||
}
|
||||
|
||||
private BookMetadata parseBookDetails(String url) {
|
||||
try {
|
||||
log.info("Parsing book details from: {}", url);
|
||||
|
||||
Document doc = Jsoup.connect(url)
|
||||
.userAgent(USER_AGENT)
|
||||
.timeout(CONNECTION_TIMEOUT_MS)
|
||||
.get();
|
||||
|
||||
BookMetadata metadata = new BookMetadata();
|
||||
metadata.setProvider(MetadataProvider.Lubimyczytac);
|
||||
|
||||
// Extract LubimyCzytac ID from URL (e.g., /ksiazka/123456/title -> 123456)
|
||||
String id = extractIdFromUrl(url);
|
||||
metadata.setLubimyczytacId(id);
|
||||
|
||||
Element titleElement = doc.selectFirst("h1.book__title");
|
||||
if (titleElement != null) {
|
||||
metadata.setTitle(titleElement.text().trim());
|
||||
}
|
||||
|
||||
Element coverElement = doc.selectFirst(".book-cover img");
|
||||
if (coverElement != null) {
|
||||
String coverUrl = coverElement.attr("src");
|
||||
if (coverUrl != null && !coverUrl.isEmpty()) {
|
||||
metadata.setThumbnailUrl(coverUrl.startsWith("http") ? coverUrl : BASE_URL + coverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Element publisherElement = doc.selectFirst("a[href*=/wydawnictwo/]");
|
||||
if (publisherElement != null) {
|
||||
metadata.setPublisher(publisherElement.text().trim());
|
||||
}
|
||||
|
||||
Elements languageElements = doc.select("dt:contains(Język:) + dd");
|
||||
if (!languageElements.isEmpty()) {
|
||||
String language = languageElements.first().text().trim().toLowerCase();
|
||||
metadata.setLanguage(mapLanguage(language));
|
||||
}
|
||||
|
||||
Element descElement = doc.selectFirst(".collapse-content");
|
||||
if (descElement != null) {
|
||||
String description = descElement.text();
|
||||
if (description != null && !description.isBlank()) {
|
||||
metadata.setDescription(description);
|
||||
}
|
||||
}
|
||||
|
||||
Element isbnMeta = doc.selectFirst("meta[property=books:isbn]");
|
||||
if (isbnMeta != null) {
|
||||
String isbn = isbnMeta.attr("content").trim();
|
||||
if (isbn.length() == 13) {
|
||||
metadata.setIsbn13(isbn);
|
||||
} else if (isbn.length() == 10) {
|
||||
metadata.setIsbn10(isbn);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert from 10-point to 5-point scale
|
||||
Element ratingMeta = doc.selectFirst("meta[property=books:rating:value]");
|
||||
if (ratingMeta != null) {
|
||||
try {
|
||||
String ratingStr = ratingMeta.attr("content").trim();
|
||||
if (!ratingStr.isEmpty()) {
|
||||
double rating = Double.parseDouble(ratingStr);
|
||||
metadata.setLubimyczytacRating(rating / RATING_SCALE_DIVISOR);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse rating for book: {}", url, e);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> tags = new HashSet<>();
|
||||
Elements tagElements = doc.select("a[href*=/ksiazki/t/]");
|
||||
for (Element tagElement : tagElements) {
|
||||
String tag = tagElement.text().trim();
|
||||
if (!tag.isEmpty()) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
if (!tags.isEmpty()) {
|
||||
metadata.setTags(tags);
|
||||
}
|
||||
|
||||
// Series format: "Cykl: Series Name (tom 3)" or "Cykl: Series Name"
|
||||
Elements seriesElements = doc.select("span.d-none.d-sm-block.mt-1:contains(Cykl:)");
|
||||
if (!seriesElements.isEmpty()) {
|
||||
String seriesText = seriesElements.first().text().trim();
|
||||
parseSeriesInfo(seriesText, metadata);
|
||||
}
|
||||
|
||||
// Extract authors, categories, pages, and publish date from JSON-LD structured data
|
||||
Elements jsonLdElements = doc.select("script[type=application/ld+json]");
|
||||
for (Element jsonLdElement : jsonLdElements) {
|
||||
try {
|
||||
String jsonLd = jsonLdElement.html();
|
||||
parseJsonLd(jsonLd, metadata);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSON-LD", e);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Error parsing book details from: {}", url, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractIdFromUrl(String url) {
|
||||
// Extract ID from URL like https://lubimyczytac.pl/ksiazka/123456/title
|
||||
try {
|
||||
Matcher matcher = BOOK_ID_PATTERN.matcher(url);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not extract ID from URL: {}", url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isbnMatches(BookMetadata metadata, String searchIsbn) {
|
||||
// Normalize ISBN by removing hyphens and spaces for comparison
|
||||
String normalizedSearch = searchIsbn.replaceAll("[\\s-]", "");
|
||||
|
||||
// Check ISBN-13
|
||||
if (metadata.getIsbn13() != null) {
|
||||
String normalized13 = metadata.getIsbn13().replaceAll("[\\s-]", "");
|
||||
if (normalized13.equals(normalizedSearch)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check ISBN-10
|
||||
if (metadata.getIsbn10() != null) {
|
||||
String normalized10 = metadata.getIsbn10().replaceAll("[\\s-]", "");
|
||||
if (normalized10.equals(normalizedSearch)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String mapLanguage(String polishLanguage) {
|
||||
switch (polishLanguage) {
|
||||
case "polski":
|
||||
return "pl";
|
||||
case "angielski":
|
||||
return "en";
|
||||
case "niemiecki":
|
||||
return "de";
|
||||
case "francuski":
|
||||
return "fr";
|
||||
case "hiszpański":
|
||||
return "es";
|
||||
case "włoski":
|
||||
return "it";
|
||||
default:
|
||||
return polishLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
private void parseSeriesInfo(String seriesText, BookMetadata metadata) {
|
||||
// Format: "Cykl: Series Name (tom 3)" or "Cykl: Series Name"
|
||||
if (seriesText.startsWith("Cykl:")) {
|
||||
seriesText = seriesText.substring(5).trim();
|
||||
}
|
||||
|
||||
// Extract series number if present
|
||||
var matcher = SERIES_NUMBER_PATTERN.matcher(seriesText);
|
||||
|
||||
if (matcher.find()) {
|
||||
try {
|
||||
float seriesNumber = Float.parseFloat(matcher.group(1));
|
||||
metadata.setSeriesNumber(seriesNumber);
|
||||
|
||||
// Remove the "(tom X)" part to get series name
|
||||
String seriesName = SERIES_NUMBER_PATTERN.matcher(seriesText).replaceAll("").trim();
|
||||
metadata.setSeriesName(seriesName);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse series number from: {}", seriesText);
|
||||
metadata.setSeriesName(seriesText);
|
||||
}
|
||||
} else {
|
||||
metadata.setSeriesName(seriesText);
|
||||
}
|
||||
}
|
||||
|
||||
private void parseJsonLd(String jsonLd, BookMetadata metadata) {
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(jsonLd);
|
||||
|
||||
// Pages
|
||||
if (root.has("numberOfPages")) {
|
||||
try {
|
||||
int pages = root.get("numberOfPages").asInt();
|
||||
metadata.setPageCount(pages);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse numberOfPages from JSON-LD");
|
||||
}
|
||||
}
|
||||
|
||||
// Publish date
|
||||
if (root.has("datePublished")) {
|
||||
try {
|
||||
String dateStr = root.get("datePublished").asText();
|
||||
LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE);
|
||||
metadata.setPublishedDate(date);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse datePublished from JSON-LD: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Author(s) - can be single Person or array of Person objects
|
||||
if (root.has("author")) {
|
||||
try {
|
||||
Set<String> authors = new HashSet<>();
|
||||
JsonNode authorNode = root.get("author");
|
||||
|
||||
if (authorNode.isArray()) {
|
||||
// Multiple authors
|
||||
for (JsonNode author : authorNode) {
|
||||
if (author.has("name")) {
|
||||
authors.add(author.get("name").asText());
|
||||
}
|
||||
}
|
||||
} else if (authorNode.isObject() && authorNode.has("name")) {
|
||||
// Single author
|
||||
authors.add(authorNode.get("name").asText());
|
||||
}
|
||||
|
||||
if (!authors.isEmpty()) {
|
||||
metadata.setAuthors(authors);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse author from JSON-LD: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Genre/Category - extracted from genre URL
|
||||
if (root.has("genre")) {
|
||||
try {
|
||||
String genreUrl = root.get("genre").asText();
|
||||
// Extract category name from URL like "https://lubimyczytac.pl/ksiazki/k/69/poradniki"
|
||||
// Get the last segment after the final slash
|
||||
String[] urlParts = genreUrl.split("/");
|
||||
if (urlParts.length > 0) {
|
||||
String categoryName = urlParts[urlParts.length - 1];
|
||||
if (!categoryName.isEmpty()) {
|
||||
Set<String> categories = new HashSet<>();
|
||||
categories.add(categoryName);
|
||||
metadata.setCategories(categories);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse genre from JSON-LD: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSON-LD structure", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ public class MetadataChangeDetector {
|
||||
compare(changes, "goodreadsReviewCount", clear.isGoodreadsReviewCount(), newMeta.getGoodreadsReviewCount(), existingMeta.getGoodreadsReviewCount(), () -> !isTrue(existingMeta.getGoodreadsReviewCountLocked()), newMeta.getGoodreadsReviewCountLocked(), existingMeta.getGoodreadsReviewCountLocked());
|
||||
compare(changes, "hardcoverRating", clear.isHardcoverRating(), newMeta.getHardcoverRating(), existingMeta.getHardcoverRating(), () -> !isTrue(existingMeta.getHardcoverRatingLocked()), newMeta.getHardcoverRatingLocked(), existingMeta.getHardcoverRatingLocked());
|
||||
compare(changes, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()), newMeta.getHardcoverReviewCountLocked(), existingMeta.getHardcoverReviewCountLocked());
|
||||
compare(changes, "lubimyczytacId", clear.isLubimyczytacId(), newMeta.getLubimyczytacId(), existingMeta.getLubimyczytacId(), () -> !isTrue(existingMeta.getLubimyczytacIdLocked()), newMeta.getLubimyczytacIdLocked(), existingMeta.getLubimyczytacIdLocked());
|
||||
compare(changes, "lubimyczytacRating", clear.isLubimyczytacRating(), newMeta.getLubimyczytacRating(), existingMeta.getLubimyczytacRating(), () -> !isTrue(existingMeta.getLubimyczytacRatingLocked()), newMeta.getLubimyczytacRatingLocked(), existingMeta.getLubimyczytacRatingLocked());
|
||||
compare(changes, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()), newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked());
|
||||
compare(changes, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()), newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked());
|
||||
compare(changes, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked()), newMeta.getMoodsLocked(), existingMeta.getMoodsLocked());
|
||||
@@ -86,6 +88,8 @@ public class MetadataChangeDetector {
|
||||
compareValue(diffs, "goodreadsReviewCount", clear.isGoodreadsReviewCount(), newMeta.getGoodreadsReviewCount(), existingMeta.getGoodreadsReviewCount(), () -> !isTrue(existingMeta.getGoodreadsReviewCountLocked()));
|
||||
compareValue(diffs, "hardcoverRating", clear.isHardcoverRating(), newMeta.getHardcoverRating(), existingMeta.getHardcoverRating(), () -> !isTrue(existingMeta.getHardcoverRatingLocked()));
|
||||
compareValue(diffs, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()));
|
||||
compareValue(diffs, "lubimyczytacId", clear.isLubimyczytacId(), newMeta.getLubimyczytacId(), existingMeta.getLubimyczytacId(), () -> !isTrue(existingMeta.getLubimyczytacIdLocked()));
|
||||
compareValue(diffs, "lubimyczytacRating", clear.isLubimyczytacRating(), newMeta.getLubimyczytacRating(), existingMeta.getLubimyczytacRating(), () -> !isTrue(existingMeta.getLubimyczytacRatingLocked()));
|
||||
compareValue(diffs, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()));
|
||||
compareValue(diffs, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()));
|
||||
compareValue(diffs, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked()));
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add LubimyCzytac metadata columns
|
||||
ALTER TABLE book_metadata
|
||||
ADD COLUMN IF NOT EXISTS lubimyczytac_id VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS lubimyczytac_rating FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS lubimyczytac_id_locked BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS lubimyczytac_rating_locked BOOLEAN DEFAULT FALSE;
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.adityachandel.booklore.service.metadata.parser;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class LubimyCzytacParserTest {
|
||||
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
|
||||
@InjectMocks
|
||||
private LubimyCzytacParser parser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchMetadata_ProviderDisabled() {
|
||||
// Given
|
||||
Book book = Book.builder()
|
||||
.title("Test Book")
|
||||
.build();
|
||||
|
||||
FetchMetadataRequest request = FetchMetadataRequest.builder()
|
||||
.title("Test Book")
|
||||
.build();
|
||||
|
||||
// Mock disabled provider
|
||||
AppSettings appSettings = new AppSettings();
|
||||
MetadataProviderSettings providerSettings = new MetadataProviderSettings();
|
||||
MetadataProviderSettings.Lubimyczytac lubimyCzytac = new MetadataProviderSettings.Lubimyczytac();
|
||||
lubimyCzytac.setEnabled(false);
|
||||
providerSettings.setLubimyczytac(lubimyCzytac);
|
||||
appSettings.setMetadataProviderSettings(providerSettings);
|
||||
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
// When
|
||||
List<BookMetadata> results = parser.fetchMetadata(book, request);
|
||||
|
||||
// Then
|
||||
assertNotNull(results);
|
||||
assertTrue(results.isEmpty(), "Should return empty list when provider is disabled");
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchMetadata_ProviderSettingsNull() {
|
||||
// Given
|
||||
Book book = Book.builder()
|
||||
.title("Test Book")
|
||||
.build();
|
||||
|
||||
FetchMetadataRequest request = FetchMetadataRequest.builder()
|
||||
.title("Test Book")
|
||||
.build();
|
||||
|
||||
// Mock null settings
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataProviderSettings(null);
|
||||
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
// When
|
||||
List<BookMetadata> results = parser.fetchMetadata(book, request);
|
||||
|
||||
// Then
|
||||
assertNotNull(results);
|
||||
assertTrue(results.isEmpty(), "Should return empty list when settings are null");
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchMetadata_EmptyQuery() {
|
||||
// Given
|
||||
Book book = Book.builder()
|
||||
.title("Test Book")
|
||||
.build();
|
||||
|
||||
FetchMetadataRequest request = FetchMetadataRequest.builder()
|
||||
.build();
|
||||
// Empty query - no title or ISBN
|
||||
|
||||
// Mock enabled provider
|
||||
AppSettings appSettings = new AppSettings();
|
||||
MetadataProviderSettings providerSettings = new MetadataProviderSettings();
|
||||
MetadataProviderSettings.Lubimyczytac lubimyCzytac = new MetadataProviderSettings.Lubimyczytac();
|
||||
lubimyCzytac.setEnabled(true);
|
||||
providerSettings.setLubimyczytac(lubimyCzytac);
|
||||
appSettings.setMetadataProviderSettings(providerSettings);
|
||||
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
// When
|
||||
List<BookMetadata> results = parser.fetchMetadata(book, request);
|
||||
|
||||
// Then
|
||||
assertNotNull(results);
|
||||
assertTrue(results.isEmpty(), "Should return empty list when query is empty");
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled("Integration test - requires network access to LubimyCzytac.pl")
|
||||
void testFetchMetadata_Integration_RealBook() {
|
||||
// Given
|
||||
Book book = Book.builder()
|
||||
.title("Wiedźmin")
|
||||
.build();
|
||||
|
||||
FetchMetadataRequest request = FetchMetadataRequest.builder()
|
||||
.title("Wiedźmin")
|
||||
.author("Andrzej Sapkowski")
|
||||
.build();
|
||||
|
||||
// Mock enabled provider
|
||||
AppSettings appSettings = new AppSettings();
|
||||
MetadataProviderSettings providerSettings = new MetadataProviderSettings();
|
||||
MetadataProviderSettings.Lubimyczytac lubimyCzytac = new MetadataProviderSettings.Lubimyczytac();
|
||||
lubimyCzytac.setEnabled(true);
|
||||
providerSettings.setLubimyczytac(lubimyCzytac);
|
||||
appSettings.setMetadataProviderSettings(providerSettings);
|
||||
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
// When
|
||||
List<BookMetadata> results = parser.fetchMetadata(book, request);
|
||||
|
||||
// Then
|
||||
assertNotNull(results);
|
||||
assertFalse(results.isEmpty(), "Should return results for real book");
|
||||
|
||||
BookMetadata firstResult = results.get(0);
|
||||
assertNotNull(firstResult.getTitle(), "Title should be present");
|
||||
assertNotNull(firstResult.getLubimyczytacId(), "LubimyCzytac ID should be present");
|
||||
assertTrue(firstResult.getAuthors() != null && !firstResult.getAuthors().isEmpty(),
|
||||
"Authors should be present");
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,8 @@ export interface BookMetadata {
|
||||
goodreadsReviewCount?: number | null;
|
||||
hardcoverRating?: number | null;
|
||||
hardcoverReviewCount?: number | null;
|
||||
lubimyczytacId?: string;
|
||||
lubimyczytacRating?: number | null;
|
||||
coverUpdatedOn?: string;
|
||||
authors?: string[];
|
||||
categories?: string[];
|
||||
@@ -134,6 +136,8 @@ export interface BookMetadata {
|
||||
goodreadsReviewCountLocked?: boolean;
|
||||
hardcoverRatingLocked?: boolean;
|
||||
hardcoverReviewCountLocked?: boolean;
|
||||
lubimyczytacIdLocked?: boolean;
|
||||
lubimyczytacRatingLocked?: boolean;
|
||||
coverUpdatedOnLocked?: boolean;
|
||||
authorsLocked?: boolean;
|
||||
categoriesLocked?: boolean;
|
||||
@@ -169,6 +173,8 @@ export interface MetadataClearFlags {
|
||||
goodreadsReviewCount?: boolean;
|
||||
hardcoverRating?: boolean;
|
||||
hardcoverReviewCount?: boolean;
|
||||
lubimyczytacId?: boolean;
|
||||
lubimyczytacRating?: boolean;
|
||||
authors?: boolean;
|
||||
categories?: boolean;
|
||||
moods?: boolean;
|
||||
|
||||
@@ -507,6 +507,30 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:w-1/6">
|
||||
<label class="text-sm" for="lubimyczytacId">LC ID</label>
|
||||
<div class="flex withbutton">
|
||||
<input pSize="small" pInputText id="lubimyczytacId" formControlName="lubimyczytacId" class="w-full"/>
|
||||
@if (!book.metadata!['lubimyczytacIdLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('lubimyczytacId')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['lubimyczytacIdLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('lubimyczytacId')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:w-1/6">
|
||||
<label class="text-sm" for="lubimyczytacRating">LC ★</label>
|
||||
<div class="flex withbutton">
|
||||
<input pSize="small" pInputText id="lubimyczytacRating" formControlName="lubimyczytacRating" class="w-full"/>
|
||||
@if (!book.metadata!['lubimyczytacRatingLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('lubimyczytacRating')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['lubimyczytacRatingLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('lubimyczytacRating')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:w-1/6">
|
||||
<label class="text-sm" for="comicvineId">Comicvine ID</label>
|
||||
<div class="flex withbutton">
|
||||
|
||||
@@ -166,6 +166,8 @@ export class MetadataEditorComponent implements OnInit {
|
||||
hardcoverBookId: new FormControl(""),
|
||||
hardcoverRating: new FormControl(""),
|
||||
hardcoverReviewCount: new FormControl(""),
|
||||
lubimyczytacId: new FormControl(""),
|
||||
lubimyczytacRating: new FormControl(""),
|
||||
googleId: new FormControl(""),
|
||||
seriesName: new FormControl(""),
|
||||
seriesNumber: new FormControl(""),
|
||||
@@ -196,6 +198,8 @@ export class MetadataEditorComponent implements OnInit {
|
||||
hardcoverBookIdLocked: new FormControl(false),
|
||||
hardcoverRatingLocked: new FormControl(false),
|
||||
hardcoverReviewCountLocked: new FormControl(false),
|
||||
lubimyczytacIdLocked: new FormControl(false),
|
||||
lubimyczytacRatingLocked: new FormControl(false),
|
||||
googleIdLocked: new FormControl(false),
|
||||
seriesNameLocked: new FormControl(false),
|
||||
seriesNumberLocked: new FormControl(false),
|
||||
@@ -296,6 +300,8 @@ export class MetadataEditorComponent implements OnInit {
|
||||
hardcoverBookId: metadata.hardcoverBookId ?? null,
|
||||
hardcoverRating: metadata.hardcoverRating ?? null,
|
||||
hardcoverReviewCount: metadata.hardcoverReviewCount ?? null,
|
||||
lubimyczytacId: metadata.lubimyczytacId ?? null,
|
||||
lubimyczytacRating: metadata.lubimyczytacRating ?? null,
|
||||
googleId: metadata.googleId ?? null,
|
||||
seriesName: metadata.seriesName ?? null,
|
||||
seriesNumber: metadata.seriesNumber ?? null,
|
||||
@@ -324,6 +330,8 @@ export class MetadataEditorComponent implements OnInit {
|
||||
hardcoverBookIdLocked: metadata.hardcoverBookIdLocked ?? false,
|
||||
hardcoverRatingLocked: metadata.hardcoverRatingLocked ?? false,
|
||||
hardcoverReviewCountLocked: metadata.hardcoverReviewCountLocked ?? false,
|
||||
lubimyczytacIdLocked: metadata.lubimyczytacIdLocked ?? false,
|
||||
lubimyczytacRatingLocked: metadata.lubimyczytacRatingLocked ?? false,
|
||||
googleIdLocked: metadata.googleIdLocked ?? false,
|
||||
seriesNameLocked: metadata.seriesNameLocked ?? false,
|
||||
seriesNumberLocked: metadata.seriesNumberLocked ?? false,
|
||||
|
||||
@@ -76,6 +76,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
{label: 'Hardcover Book ID', controlName: 'hardcoverBookId', lockedKey: 'hardcoverBookIdLocked', fetchedKey: 'hardcoverBookId'},
|
||||
{label: 'HC Reviews', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'},
|
||||
{label: 'HC Rating', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'},
|
||||
{label: 'LC ID', controlName: 'lubimyczytacId', lockedKey: 'lubimyczytacIdLocked', fetchedKey: 'lubimyczytacId'},
|
||||
{label: 'LC Rating', controlName: 'lubimyczytacRating', lockedKey: 'lubimyczytacRatingLocked', fetchedKey: 'lubimyczytacRating'},
|
||||
{label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'},
|
||||
{label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'}
|
||||
];
|
||||
@@ -153,6 +155,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookId: new FormControl(''),
|
||||
hardcoverRating: new FormControl(''),
|
||||
hardcoverReviewCount: new FormControl(''),
|
||||
lubimyczytacId: new FormControl(''),
|
||||
lubimyczytacRating: new FormControl(''),
|
||||
googleId: new FormControl(''),
|
||||
seriesName: new FormControl(''),
|
||||
seriesNumber: new FormControl(''),
|
||||
@@ -183,6 +187,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookIdLocked: new FormControl(false),
|
||||
hardcoverRatingLocked: new FormControl(false),
|
||||
hardcoverReviewCountLocked: new FormControl(false),
|
||||
lubimyczytacIdLocked: new FormControl(false),
|
||||
lubimyczytacRatingLocked: new FormControl(false),
|
||||
googleIdLocked: new FormControl(false),
|
||||
seriesNameLocked: new FormControl(false),
|
||||
seriesNumberLocked: new FormControl(false),
|
||||
@@ -260,6 +266,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookId: metadata.hardcoverBookId || null,
|
||||
hardcoverRating: metadata.hardcoverRating || null,
|
||||
hardcoverReviewCount: metadata.hardcoverReviewCount || null,
|
||||
lubimyczytacId: metadata.lubimyczytacId || null,
|
||||
lubimyczytacRating: metadata.lubimyczytacRating || null,
|
||||
googleId: metadata.googleId || null,
|
||||
seriesName: metadata.seriesName || null,
|
||||
seriesNumber: metadata.seriesNumber || null,
|
||||
@@ -290,6 +298,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookIdLocked: metadata.hardcoverBookIdLocked || false,
|
||||
hardcoverRatingLocked: metadata.hardcoverRatingLocked || false,
|
||||
hardcoverReviewCountLocked: metadata.hardcoverReviewCountLocked || false,
|
||||
lubimyczytacIdLocked: metadata.lubimyczytacIdLocked || false,
|
||||
lubimyczytacRatingLocked: metadata.lubimyczytacRatingLocked || false,
|
||||
googleIdLocked: metadata.googleIdLocked || false,
|
||||
seriesNameLocked: metadata.seriesNameLocked || false,
|
||||
seriesNumberLocked: metadata.seriesNumberLocked || false,
|
||||
@@ -327,6 +337,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
if (metadata.hardcoverBookIdLocked) this.metadataForm.get('hardcoverBookId')?.disable({emitEvent: false});
|
||||
if (metadata.hardcoverReviewCountLocked) this.metadataForm.get('hardcoverReviewCount')?.disable({emitEvent: false});
|
||||
if (metadata.hardcoverRatingLocked) this.metadataForm.get('hardcoverRating')?.disable({emitEvent: false});
|
||||
if (metadata.lubimyczytacIdLocked) this.metadataForm.get('lubimyczytacId')?.disable({emitEvent: false});
|
||||
if (metadata.lubimyczytacRatingLocked) this.metadataForm.get('lubimyczytacRating')?.disable({emitEvent: false});
|
||||
if (metadata.googleIdLocked) this.metadataForm.get('googleId')?.disable({emitEvent: false});
|
||||
if (metadata.pageCountLocked) this.metadataForm.get('pageCount')?.disable({emitEvent: false});
|
||||
if (metadata.descriptionLocked) this.metadataForm.get('description')?.disable({emitEvent: false});
|
||||
@@ -406,6 +418,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookId: this.metadataForm.get('hardcoverBookId')?.value || this.copiedFields['hardcoverBookId'] ? (this.getNumberOrCopied('hardcoverBookId') ?? null) : null,
|
||||
hardcoverRating: this.metadataForm.get('hardcoverRating')?.value || this.copiedFields['hardcoverRating'] ? this.getNumberOrCopied('hardcoverRating') : null,
|
||||
hardcoverReviewCount: this.metadataForm.get('hardcoverReviewCount')?.value || this.copiedFields['hardcoverReviewCount'] ? this.getNumberOrCopied('hardcoverReviewCount') : null,
|
||||
lubimyczytacId: this.metadataForm.get('lubimyczytacId')?.value || this.copiedFields['lubimyczytacId'] ? this.getValueOrCopied('lubimyczytacId') : '',
|
||||
lubimyczytacRating: this.metadataForm.get('lubimyczytacRating')?.value || this.copiedFields['lubimyczytacRating'] ? this.getNumberOrCopied('lubimyczytacRating') : null,
|
||||
googleId: this.metadataForm.get('googleId')?.value || this.copiedFields['googleId'] ? this.getValueOrCopied('googleId') : '',
|
||||
seriesName: this.metadataForm.get('seriesName')?.value || this.copiedFields['seriesName'] ? this.getValueOrCopied('seriesName') : '',
|
||||
seriesNumber: this.metadataForm.get('seriesNumber')?.value || this.copiedFields['seriesNumber'] ? this.getNumberOrCopied('seriesNumber') : null,
|
||||
@@ -436,6 +450,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookIdLocked: this.metadataForm.get('hardcoverBookIdLocked')?.value,
|
||||
hardcoverRatingLocked: this.metadataForm.get('hardcoverRatingLocked')?.value,
|
||||
hardcoverReviewCountLocked: this.metadataForm.get('hardcoverReviewCountLocked')?.value,
|
||||
lubimyczytacIdLocked: this.metadataForm.get('lubimyczytacIdLocked')?.value,
|
||||
lubimyczytacRatingLocked: this.metadataForm.get('lubimyczytacRatingLocked')?.value,
|
||||
googleIdLocked: this.metadataForm.get('googleIdLocked')?.value,
|
||||
seriesNameLocked: this.metadataForm.get('seriesNameLocked')?.value,
|
||||
seriesNumberLocked: this.metadataForm.get('seriesNumberLocked')?.value,
|
||||
@@ -479,6 +495,8 @@ export class MetadataPickerComponent implements OnInit {
|
||||
hardcoverBookId: current.hardcoverBookId === null && original.hardcoverBookId !== null,
|
||||
hardcoverRating: current.hardcoverRating === null && original.hardcoverRating !== null,
|
||||
hardcoverReviewCount: current.hardcoverReviewCount === null && original.hardcoverReviewCount !== null,
|
||||
lubimyczytacId: !current.lubimyczytacId && !!original.lubimyczytacId,
|
||||
lubimyczytacRating: current.lubimyczytacRating === null && original.lubimyczytacRating !== null,
|
||||
googleId: !current.googleId && !!original.googleId,
|
||||
seriesName: !current.seriesName && !!original.seriesName,
|
||||
seriesNumber: current.seriesNumber === null && original.seriesNumber !== null,
|
||||
|
||||
@@ -189,6 +189,8 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
return `<a href="https://hardcover.app/books/${metadata.hardcoverId}" target="_blank">Hardcover</a>`;
|
||||
} else if (metadata['doubanId']) {
|
||||
return `<a href="https://book.douban.com/subject/${metadata['doubanId']}" target="_blank">Douban</a>`;
|
||||
} else if (metadata['lubimyczytacId']) {
|
||||
return `<a href="https://lubimyczytac.pl/ksiazka/${metadata['lubimyczytacId']}" target="_blank">Lubimyczytac</a>`;
|
||||
} else if (metadata.comicvineId) {
|
||||
if (metadata.comicvineId.startsWith('4000')) {
|
||||
const name = metadata.seriesName ? metadata.seriesName.replace(/ /g, '-').toLowerCase() + "-" + metadata.seriesNumber : '';
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@if (book?.metadata?.amazonRating || book?.metadata?.goodreadsRating || book?.metadata?.hardcoverRating || book?.metadata?.googleId) {
|
||||
@if (book?.metadata?.amazonRating || book?.metadata?.goodreadsRating || book?.metadata?.hardcoverRating || book?.metadata?.lubimyczytacRating || book?.metadata?.googleId) {
|
||||
<div class="gradient-divider"></div>
|
||||
}
|
||||
|
||||
@@ -201,6 +201,20 @@
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (book?.metadata?.lubimyczytacRating) {
|
||||
<a
|
||||
class="rating-link"
|
||||
[href]="'https://lubimyczytac.pl/ksiazka/' + (book.metadata?.lubimyczytacId ?? '')"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[pTooltip]="getRatingTooltip(book, 'lubimyczytac')"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<img src="https://lubimyczytac.pl/favicon.ico" alt="Lubimyczytac" class="rating-favicon"/>
|
||||
<span class="rating-value">{{ getRatingPercent(book.metadata!.lubimyczytacRating) }}%</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (book?.metadata?.googleId) {
|
||||
<a
|
||||
class="rating-link"
|
||||
|
||||
@@ -788,7 +788,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
return p != null ? Math.round(p * 10) / 10 : null;
|
||||
}
|
||||
|
||||
getRatingTooltip(book: Book, source: 'amazon' | 'goodreads' | 'hardcover'): string {
|
||||
getRatingTooltip(book: Book, source: 'amazon' | 'goodreads' | 'hardcover' | 'lubimyczytac'): string {
|
||||
const meta = book?.metadata;
|
||||
if (!meta) return '';
|
||||
|
||||
@@ -805,6 +805,10 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
return meta.hardcoverRating != null
|
||||
? `★ ${meta.hardcoverRating} | ${meta.hardcoverReviewCount?.toLocaleString() ?? '0'} reviews`
|
||||
: '';
|
||||
case 'lubimyczytac':
|
||||
return meta.lubimyczytacRating != null
|
||||
? `★ ${meta.lubimyczytacRating}`
|
||||
: '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -25,15 +25,17 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate',
|
||||
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
|
||||
'language', 'categories', 'cover', 'pageCount',
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', 'lubimyczytacId',
|
||||
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'lubimyczytacRating',
|
||||
'moods', 'tags'
|
||||
];
|
||||
|
||||
providerSpecificFields: (keyof FieldOptions)[] = [
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', 'lubimyczytacId',
|
||||
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'lubimyczytacRating',
|
||||
'moods', 'tags'
|
||||
];
|
||||
|
||||
nonProviderSpecificFields: (keyof FieldOptions)[] = [
|
||||
@@ -42,8 +44,8 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
'language', 'categories', 'cover', 'pageCount',
|
||||
];
|
||||
|
||||
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
|
||||
providersWithClear: string[] = ['Clear All', 'Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
|
||||
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban', 'Lubimyczytac'];
|
||||
providersWithClear: string[] = ['Clear All', 'Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban', 'Lubimyczytac'];
|
||||
|
||||
refreshCovers: boolean = false;
|
||||
mergeCategories: boolean = false;
|
||||
@@ -62,9 +64,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
private justSubmitted = false;
|
||||
|
||||
private providerSpecificFieldsList = [
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
|
||||
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', 'lubimyczytacId',
|
||||
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
|
||||
'hardcoverRating', 'hardcoverReviewCount', 'lubimyczytacRating',
|
||||
'moods', 'tags'
|
||||
];
|
||||
|
||||
private initializeFieldOptions(): FieldOptions {
|
||||
@@ -228,6 +231,8 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
'goodreadsReviewCount': 'Goodreads Review Count',
|
||||
'hardcoverRating': 'Hardcover Rating',
|
||||
'hardcoverReviewCount': 'Hardcover Review Count',
|
||||
'lubimyczytacId': 'LC ID',
|
||||
'lubimyczytacRating': 'Lubimyczytac Rating',
|
||||
'moods': 'Moods (Hardcover)',
|
||||
'tags': 'Tags (Hardcover)'
|
||||
};
|
||||
|
||||
@@ -35,12 +35,14 @@ export interface FieldOptions {
|
||||
comicvineId: FieldProvider;
|
||||
hardcoverId: FieldProvider;
|
||||
googleId: FieldProvider;
|
||||
lubimyczytacId: FieldProvider;
|
||||
amazonRating: FieldProvider;
|
||||
amazonReviewCount: FieldProvider;
|
||||
goodreadsRating: FieldProvider;
|
||||
goodreadsReviewCount: FieldProvider;
|
||||
hardcoverRating: FieldProvider;
|
||||
hardcoverReviewCount: FieldProvider;
|
||||
lubimyczytacRating: FieldProvider;
|
||||
moods: FieldProvider;
|
||||
tags: FieldProvider;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-item">
|
||||
<div class="provider-control">
|
||||
<div class="provider-info">
|
||||
<p-checkbox [(ngModel)]="lubimyCzytacEnabled" [binary]="true"></p-checkbox>
|
||||
<label class="provider-label">Lubimyczytac</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-actions">
|
||||
|
||||
@@ -76,6 +76,7 @@ export class MetadataProviderSettingsComponent implements OnInit {
|
||||
comicvineEnabled: boolean = false;
|
||||
comicvineToken: string = '';
|
||||
doubanEnabled: boolean = false;
|
||||
lubimyCzytacEnabled: boolean = false;
|
||||
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private messageService = inject(MessageService);
|
||||
@@ -101,6 +102,7 @@ export class MetadataProviderSettingsComponent implements OnInit {
|
||||
this.comicvineEnabled = metadataProviderSettings?.comicvine?.enabled ?? false;
|
||||
this.comicvineToken = metadataProviderSettings?.comicvine?.apiKey ?? '';
|
||||
this.doubanEnabled = metadataProviderSettings?.douban?.enabled ?? false;
|
||||
this.lubimyCzytacEnabled = metadataProviderSettings?.lubimyczytac?.enabled ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,7 +145,8 @@ export class MetadataProviderSettingsComponent implements OnInit {
|
||||
enabled: this.hardcoverEnabled,
|
||||
apiKey: this.hardcoverToken.trim()
|
||||
},
|
||||
douban: {enabled: this.doubanEnabled}
|
||||
douban: {enabled: this.doubanEnabled},
|
||||
lubimyczytac: {enabled: this.lubimyCzytacEnabled}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -215,6 +215,8 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
goodreadsReviewCount: {p1: null, p2: null, p3: null, p4: null},
|
||||
hardcoverRating: {p1: null, p2: null, p3: null, p4: null},
|
||||
hardcoverReviewCount: {p1: null, p2: null, p3: null, p4: null},
|
||||
lubimyczytacId: {p1: null, p2: null, p3: null, p4: null},
|
||||
lubimyczytacRating: {p1: null, p2: null, p3: null, p4: null},
|
||||
moods: {p1: null, p2: null, p3: null, p4: null},
|
||||
tags: {p1: null, p2: null, p3: null, p4: null}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MetadataMatchWeights {
|
||||
hardcoverReviewCount: number;
|
||||
doubanRating: number;
|
||||
doubanReviewCount: number;
|
||||
lubimyczytacRating: number;
|
||||
coverImage: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ export interface MetadataProviderSettings {
|
||||
hardcover: Hardcover;
|
||||
comicvine: Comicvine;
|
||||
douban: Douban;
|
||||
lubimyczytac: Lubimyczytac;
|
||||
}
|
||||
|
||||
export interface Amazon {
|
||||
@@ -81,6 +83,10 @@ export interface Douban {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Lubimyczytac {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MetadataPersistenceSettings {
|
||||
moveFilesToLibraryPattern: boolean;
|
||||
saveToOriginalFile: boolean;
|
||||
|
||||
Reference in New Issue
Block a user