From 7094b4fd2eb5da76b92ac9bcaefd6246c1d13ea4 Mon Sep 17 00:00:00 2001 From: "Kayashov.SM" Date: Sat, 19 Sep 2026 23:35:55 +0400 Subject: [PATCH] Add pagination to the Recommendations feed Growing libraries made the Home page slow to render and its response payload large - filtering (type/source) now happens server-side and the result is sliced into pages before serialization, instead of shipping every scored candidate to the frontend on every load. Co-Authored-By: Claude Sonnet 5 --- frontend/src/api/client.js | 7 +- frontend/src/pages/Home.jsx | 85 +++++++++++-------- .../controller/RecommendationController.java | 16 +++- .../com/recommendarr/dto/PagedResult.java | 8 ++ .../recommendarr/service/ScoringService.java | 37 ++++++++ 5 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/recommendarr/dto/PagedResult.java diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9ff16a3..4899f7b 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -15,7 +15,12 @@ async function request(path, options = {}) { } export const api = { - getRecommendations: () => request('/api/recommendations'), + getRecommendations: ({ type, source, page = 0, size = 24 } = {}) => { + const params = new URLSearchParams({ page, size }); + if (type && type !== 'ALL') params.set('type', type); + if (source && source !== 'ALL') params.set('source', source); + return request(`/api/recommendations?${params.toString()}`); + }, search: (q) => request('/api/search?q=' + encodeURIComponent(q)), getWatched: (hiddenOnly) => request(`/api/watched?hiddenOnly=${hiddenOnly ? 'true' : 'false'}`), getRecommendedBy: (id) => request(`/api/media/${id}/recommends`), diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 44b8b3f..7691e81 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { api } from '../api/client.js'; import MediaCard from '../components/MediaCard.jsx'; @@ -6,85 +6,102 @@ const TYPE_LABELS = { MOVIE: 'Фильм', SERIES: 'Сериал', CARTOON: 'М // Individual options isolate a single provider ("found by this one only"); // BOTH is the separate case of every provider agreeing - together they're // mutually exclusive and cover every possible discoverySources value. +// Kept here (in addition to ScoringService.matchesSourceFilter on the +// backend) only for the button labels - the actual filtering now happens +// server-side. const SOURCE_OPTIONS = [ - { key: 'TMDB', label: 'Только TMDB', test: (sources) => sources.length === 1 && sources.includes('TMDB') }, - { key: 'KINOPOISK', label: 'Только Кинопоиск', test: (sources) => sources.length === 1 && sources.includes('KINOPOISK') }, - { key: 'BOTH', label: 'Оба источника', test: (sources) => sources.length > 1 } + { key: 'TMDB', label: 'Только TMDB' }, + { key: 'KINOPOISK', label: 'Только Кинопоиск' }, + { key: 'BOTH', label: 'Оба источника' } ]; +const PAGE_SIZE = 24; + export default function Home() { - const [items, setItems] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); const [typeFilter, setTypeFilter] = useState('ALL'); - // Independent from typeFilter (both apply at once, AND'ed together) - lets - // you isolate what a specific discovery provider (e.g. Kinopoisk) actually - // found, on top of any type filtering. const [sourceFilter, setSourceFilter] = useState('ALL'); + const [page, setPage] = useState(0); const load = useCallback(() => { - api.getRecommendations().then(setItems).catch((e) => setError(e.message)); - }, []); + api.getRecommendations({ type: typeFilter, source: sourceFilter, page, size: PAGE_SIZE }) + .then(setResult).catch((e) => setError(e.message)); + }, [typeFilter, sourceFilter, page]); useEffect(() => { load(); }, [load]); - const filtered = useMemo(() => { - if (!items) return items; - let list = items; - if (typeFilter !== 'ALL') list = list.filter((m) => m.type === typeFilter); - if (sourceFilter !== 'ALL') { - const option = SOURCE_OPTIONS.find((o) => o.key === sourceFilter); - list = list.filter((m) => option.test(m.discoverySources || [])); - } - return list; - }, [items, typeFilter, sourceFilter]); + // Any filter change invalidates the current page number (a different, + // usually smaller, result set) - always restart from the first page. + function changeTypeFilter(key) { + setTypeFilter(key); + setPage(0); + } + + function changeSourceFilter(key) { + setSourceFilter(key); + setPage(0); + } + + const items = result?.content ?? null; + const hasAny = result && result.totalUnfiltered > 0; return (

Рекомендации

- {items && items.length > 0 && ( + {hasAny && (
- + {Object.entries(TYPE_LABELS).map(([key, label]) => ( - ))}
- + {SOURCE_OPTIONS.map(({ key, label }) => ( - ))}
)} - {items && items.length > 0 && ( + {hasAny && (

- {filtered.length === items.length - ? `Всего: ${items.length}` - : `Показано ${filtered.length} из ${items.length}`} + {result.totalElements === result.totalUnfiltered + ? `Всего: ${result.totalElements}` + : `Показано ${result.totalElements} из ${result.totalUnfiltered}`}

)} {error &&
{error}
} - {!items && !error &&

Загрузка...

} - {items && items.length === 0 && ( + {!result && !error &&

Загрузка...

} + {result && result.totalUnfiltered === 0 && (
Пока нечего рекомендовать. Настройте интеграции в разделе «Настройки», затем нажмите «Пересчитать сейчас».
)} - {items && items.length > 0 && filtered.length === 0 && ( + {result && result.totalUnfiltered > 0 && result.totalElements === 0 && (
Ничего не подходит под выбранные фильтры.
)} - {filtered && filtered.length > 0 && ( + {items && items.length > 0 && (
- {filtered.map((m) => ( + {items.map((m) => ( ))}
)} + {result && result.totalPages > 1 && ( +
+ + + Страница {page + 1} из {result.totalPages} + + +
+ )}
); } diff --git a/src/main/java/com/recommendarr/controller/RecommendationController.java b/src/main/java/com/recommendarr/controller/RecommendationController.java index 196e075..8de4d87 100644 --- a/src/main/java/com/recommendarr/controller/RecommendationController.java +++ b/src/main/java/com/recommendarr/controller/RecommendationController.java @@ -1,6 +1,8 @@ package com.recommendarr.controller; import com.recommendarr.dto.MediaCardDto; +import com.recommendarr.dto.PagedResult; +import com.recommendarr.entity.MediaType; import com.recommendarr.service.ScoringService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -20,10 +22,18 @@ public class RecommendationController { this.scoringService = scoringService; } - /** Home screen: fresh candidates, with occasional rewatch suggestions spliced in. */ + /** + * Home screen: fresh candidates (with occasional rewatch suggestions + * spliced in), filtered by type/source and paged - scoring still runs + * over the full candidate set (needed for a correct sort), but only one + * page of cards is ever serialized to the frontend. + */ @GetMapping("/recommendations") - public List recommendations() { - return scoringService.getRecommendations(); + public PagedResult recommendations(@RequestParam(required = false) MediaType type, + @RequestParam(required = false) String source, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "24") int size) { + return scoringService.getRecommendationsPaged(type, source, page, size); } /** diff --git a/src/main/java/com/recommendarr/dto/PagedResult.java b/src/main/java/com/recommendarr/dto/PagedResult.java new file mode 100644 index 0000000..614498f --- /dev/null +++ b/src/main/java/com/recommendarr/dto/PagedResult.java @@ -0,0 +1,8 @@ +package com.recommendarr.dto; + +import java.util.List; + +/** Generic page wrapper - content already sorted/filtered upstream, this just slices it. */ +public record PagedResult(List content, int page, int size, long totalElements, int totalPages, + long totalUnfiltered) { +} diff --git a/src/main/java/com/recommendarr/service/ScoringService.java b/src/main/java/com/recommendarr/service/ScoringService.java index c445a17..1c94eb6 100644 --- a/src/main/java/com/recommendarr/service/ScoringService.java +++ b/src/main/java/com/recommendarr/service/ScoringService.java @@ -1,6 +1,7 @@ package com.recommendarr.service; import com.recommendarr.dto.MediaCardDto; +import com.recommendarr.dto.PagedResult; import com.recommendarr.entity.*; import com.recommendarr.repository.*; import org.springframework.stereotype.Service; @@ -75,6 +76,42 @@ public class ScoringService { return interleave(primary, rewatch, config.getRewatchInsertInterval()); } + /** + * Paged/filtered view of {@link #getRecommendations()} for the Home + * screen - scoring still runs over every candidate (the sort itself + * needs the full set), but filtering + slicing happens before + * serialization so a growing library doesn't balloon the response + * payload or the number of cards the frontend has to render at once. + */ + public PagedResult getRecommendationsPaged(MediaType typeFilter, String sourceFilter, + int page, int size) { + List all = getRecommendations(); + List filtered = all.stream() + .filter(dto -> typeFilter == null || dto.getType() == typeFilter) + .filter(dto -> matchesSourceFilter(dto.getDiscoverySources(), sourceFilter)) + .toList(); + + int safeSize = size <= 0 ? filtered.size() : size; + int totalElements = filtered.size(); + int totalPages = safeSize <= 0 ? 1 : Math.max(1, (int) Math.ceil(totalElements / (double) safeSize)); + int from = Math.min(Math.max(page, 0) * safeSize, totalElements); + int to = Math.min(from + safeSize, totalElements); + + return new PagedResult<>(filtered.subList(from, to), page, size, totalElements, totalPages, all.size()); + } + + /** Mirrors the frontend's SOURCE_OPTIONS semantics: TMDB/KINOPOISK isolate that provider alone, BOTH requires agreement. */ + private boolean matchesSourceFilter(Set sources, String sourceFilter) { + if (sourceFilter == null || sourceFilter.isBlank() || sourceFilter.equalsIgnoreCase("ALL")) return true; + Set s = sources == null ? Set.of() : sources; + return switch (sourceFilter.toUpperCase()) { + case "TMDB" -> s.size() == 1 && s.contains("TMDB"); + case "KINOPOISK" -> s.size() == 1 && s.contains("KINOPOISK"); + case "BOTH" -> s.size() > 1; + default -> true; + }; + } + /** * "Просмотрено" page: everything currently in the library (even if never * watched - it can't be re-added, so it still belongs here) plus