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:
Marcin Gajewski
2025-12-29 03:50:53 +01:00
committed by GitHub
parent e65aa47552
commit dfcd9db368
24 changed files with 758 additions and 14 deletions

View File

@@ -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
);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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)
;
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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);
}
}
}

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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">

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 : '';

View File

@@ -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"

View File

@@ -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 '';
}

View File

@@ -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)'
};

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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}
}
}
];

View File

@@ -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}
}

View File

@@ -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;