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:
co-authored by
Claude Sonnet 5
parent
6355820847
commit
7094b4fd2e
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user