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 <noreply@anthropic.com>
This commit is contained in:
Kayashov.SM
2026-09-19 23:35:55 +04:00
co-authored by Claude Sonnet 5
parent 6355820847
commit 7094b4fd2e
5 changed files with 115 additions and 38 deletions
+6 -1
View File
@@ -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`),
+51 -34
View File
@@ -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 (
<main className="page">
<h2>Рекомендации</h2>
{items && items.length > 0 && (
{hasAny && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className={typeFilter === 'ALL' ? 'primary' : ''} onClick={() => setTypeFilter('ALL')}>Все</button>
<button className={typeFilter === 'ALL' ? 'primary' : ''} onClick={() => changeTypeFilter('ALL')}>Все</button>
{Object.entries(TYPE_LABELS).map(([key, label]) => (
<button key={key} className={typeFilter === key ? 'primary' : ''} onClick={() => setTypeFilter(key)}>
<button key={key} className={typeFilter === key ? 'primary' : ''} onClick={() => changeTypeFilter(key)}>
{label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className={sourceFilter === 'ALL' ? 'primary' : ''} onClick={() => setSourceFilter('ALL')}>Любой источник</button>
<button className={sourceFilter === 'ALL' ? 'primary' : ''} onClick={() => changeSourceFilter('ALL')}>Любой источник</button>
{SOURCE_OPTIONS.map(({ key, label }) => (
<button key={key} className={sourceFilter === key ? 'primary' : ''} onClick={() => setSourceFilter(key)}>
<button key={key} className={sourceFilter === key ? 'primary' : ''} onClick={() => changeSourceFilter(key)}>
{label}
</button>
))}
</div>
</div>
)}
{items && items.length > 0 && (
{hasAny && (
<p style={{ color: 'var(--text-dim)', marginTop: -6 }}>
{filtered.length === items.length
? `Всего: ${items.length}`
: `Показано ${filtered.length} из ${items.length}`}
{result.totalElements === result.totalUnfiltered
? `Всего: ${result.totalElements}`
: `Показано ${result.totalElements} из ${result.totalUnfiltered}`}
</p>
)}
{error && <div className="test-result fail">{error}</div>}
{!items && !error && <p>Загрузка...</p>}
{items && items.length === 0 && (
{!result && !error && <p>Загрузка...</p>}
{result && result.totalUnfiltered === 0 && (
<div className="empty-state">
Пока нечего рекомендовать. Настройте интеграции в разделе «Настройки», затем нажмите «Пересчитать сейчас».
</div>
)}
{items && items.length > 0 && filtered.length === 0 && (
{result && result.totalUnfiltered > 0 && result.totalElements === 0 && (
<div className="empty-state">Ничего не подходит под выбранные фильтры.</div>
)}
{filtered && filtered.length > 0 && (
{items && items.length > 0 && (
<div className="grid">
{filtered.map((m) => (
{items.map((m) => (
<MediaCard key={m.id} media={m} onChanged={load} />
))}
</div>
)}
{result && result.totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginTop: 20 }}>
<button disabled={page <= 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>Назад</button>
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
Страница {page + 1} из {result.totalPages}
</span>
<button disabled={page >= result.totalPages - 1} onClick={() => setPage((p) => p + 1)}>Вперёд</button>
</div>
)}
</main>
);
}
@@ -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<MediaCardDto> recommendations() {
return scoringService.getRecommendations();
public PagedResult<MediaCardDto> 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);
}
/**
@@ -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<T>(List<T> content, int page, int size, long totalElements, int totalPages,
long totalUnfiltered) {
}
@@ -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<MediaCardDto> getRecommendationsPaged(MediaType typeFilter, String sourceFilter,
int page, int size) {
List<MediaCardDto> all = getRecommendations();
List<MediaCardDto> 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<String> sources, String sourceFilter) {
if (sourceFilter == null || sourceFilter.isBlank() || sourceFilter.equalsIgnoreCase("ALL")) return true;
Set<String> 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