Add watched/blacklist actions, Просмотрено page, rewatch insertion, overview modal
Build and Deploy / build-and-deploy (push) Successful in 1m55s
Build and Deploy / build-and-deploy (push) Successful in 1m55s
- MediaEntity gains overview + hasFile (informational only, doesn't affect IN_LIBRARY status); Snooze gains a permanent `blacklisted` flag alongside the existing temporary snoozedUntil; ScoringConfig gains rewatchInsertInterval. All new NOT NULL columns use @ColumnDefault to avoid repeating the watched_boost startup crash on the already-populated tables. - TmdbClient now captures `overview` and requests language=ru-RU so found titles get Russian title/overview when TMDB has a translation. - ScoringService: getRecommendations() now excludes already-watched titles from the main feed and instead splices one rewatch suggestion (a watched title no longer in the library) in every rewatchInsertInterval items, flagged for a distinguishing badge. Replaced getWatchAgain() with getWatched(includeHidden) for the new "Просмотрено" page: everything currently in the library (even unwatched - it can't be re-added anyway) plus everything ever watched, with blacklisted titles surfaced only when includeHidden is requested (for the "Вернуть" undo action). - MediaActionController: new /watched (mark watched, no status change), /blacklist and /unblacklist endpoints. - Frontend: removed the separate "Посмотреть ещё раз" page/route in favor of the rewatch-suggestion badge inline in Recommendations; added the "Просмотрено" page (library/in-library filter, sort, hidden-blacklist toggle); MediaCard/LibraryCard get a click-to-open MediaDetailModal showing the overview text; hid the inherited-rating badge when it's 0; added an intersection-count badge; added a type filter on Recommendations. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e31bc87c4f
commit
7dc2a4dbb6
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Route, Routes } from 'react-router-dom';
|
||||
import Home from './pages/Home.jsx';
|
||||
import WatchAgain from './pages/WatchAgain.jsx';
|
||||
import Watched from './pages/Watched.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import { api } from './api/client.js';
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function App() {
|
||||
<div className="brand">Recommendarr</div>
|
||||
<nav className="app-nav">
|
||||
<NavLink to="/" end>Рекомендации</NavLink>
|
||||
<NavLink to="/watch-again">Посмотреть ещё раз</NavLink>
|
||||
<NavLink to="/watched">Просмотрено</NavLink>
|
||||
<NavLink to="/settings">Настройки</NavLink>
|
||||
</nav>
|
||||
<button className="primary" onClick={handleRecompute} disabled={recomputing}>
|
||||
@@ -38,7 +38,7 @@ export default function App() {
|
||||
{message && <div style={{ padding: '8px 24px', color: '#9aa0a8' }}>{message}</div>}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/watch-again" element={<WatchAgain />} />
|
||||
<Route path="/watched" element={<Watched />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -16,13 +16,16 @@ async function request(path, options = {}) {
|
||||
|
||||
export const api = {
|
||||
getRecommendations: () => request('/api/recommendations'),
|
||||
getWatchAgain: () => request('/api/watch-again'),
|
||||
getWatched: (includeHidden) => request(`/api/watched?includeHidden=${includeHidden ? 'true' : 'false'}`),
|
||||
|
||||
rate: (id, rating) => request(`/api/media/${id}/rating`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating })
|
||||
}),
|
||||
snooze: (id) => request(`/api/media/${id}/snooze`, { method: 'POST' }),
|
||||
markWatched: (id) => request(`/api/media/${id}/watched`, { method: 'POST' }),
|
||||
blacklist: (id) => request(`/api/media/${id}/blacklist`, { method: 'POST' }),
|
||||
unblacklist: (id) => request(`/api/media/${id}/unblacklist`, { method: 'POST' }),
|
||||
getAddOptions: (id) => request(`/api/media/${id}/add-options`),
|
||||
addToLibrary: (id, payload) => request(`/api/media/${id}/add-to-library`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api/client.js';
|
||||
import MediaDetailModal from './MediaDetailModal.jsx';
|
||||
|
||||
const TYPE_LABELS = { MOVIE: 'Фильм', SERIES: 'Сериал', CARTOON: 'Мультфильм' };
|
||||
|
||||
export default function LibraryCard({ media, onChanged }) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [ratingDraft, setRatingDraft] = useState(media.personalRating ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const posterUrl = media.posterPath
|
||||
? (media.posterPath.startsWith('http') ? media.posterPath : `https://image.tmdb.org/t/p/w300${media.posterPath}`)
|
||||
: null;
|
||||
|
||||
async function saveRating() {
|
||||
if (ratingDraft === '' || Number.isNaN(Number(ratingDraft))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.rate(media.id, Number(ratingDraft));
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unblacklist() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.unblacklist(media.id);
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// See plan: only in-library items get status badges - the mere presence of
|
||||
// a not-in-library title on this page already implies it was watched.
|
||||
const badges = [];
|
||||
if (media.inLibrary) {
|
||||
badges.push(media.hasFile ? 'Скачано' : 'В библиотеке');
|
||||
if (media.hasFile && media.watched) badges.push('Просмотрено');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div
|
||||
className="poster"
|
||||
style={posterUrl ? { backgroundImage: `url(${posterUrl})`, cursor: 'pointer' } : { cursor: 'pointer' }}
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
{!posterUrl && 'Нет постера'}
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="title" style={{ cursor: 'pointer' }} onClick={() => setShowDetail(true)}>
|
||||
{media.title} {media.year ? `(${media.year})` : ''}
|
||||
</div>
|
||||
<div className="meta">
|
||||
<span className="badge">{TYPE_LABELS[media.type] || media.type}</span>
|
||||
</div>
|
||||
{badges.length > 0 && (
|
||||
<div className="ratings">
|
||||
{badges.map((b) => <span key={b} className="badge">{b}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ratings">
|
||||
{media.externalRating != null && <span className="r">TMDB {media.externalRating.toFixed(1)}</span>}
|
||||
</div>
|
||||
<div className="rating-input">
|
||||
<label>Моя оценка</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={ratingDraft}
|
||||
onChange={(e) => setRatingDraft(e.target.value)}
|
||||
onBlur={saveRating}
|
||||
/>
|
||||
</div>
|
||||
{media.blacklisted && (
|
||||
<div className="actions">
|
||||
<button disabled={busy} onClick={unblacklist}>Вернуть</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDetail && (
|
||||
<MediaDetailModal media={media} onClose={() => setShowDetail(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api/client.js';
|
||||
import AddToLibraryModal from './AddToLibraryModal.jsx';
|
||||
import MediaDetailModal from './MediaDetailModal.jsx';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
MOVIE: 'Фильм',
|
||||
@@ -10,6 +11,7 @@ const TYPE_LABELS = {
|
||||
|
||||
export default function MediaCard({ media, onChanged }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [ratingDraft, setRatingDraft] = useState(media.personalRating ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -28,10 +30,20 @@ export default function MediaCard({ media, onChanged }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function snooze() {
|
||||
async function markWatched() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.snooze(media.id);
|
||||
await api.markWatched(media.id);
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function blacklist() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.blacklist(media.id);
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -40,18 +52,32 @@ export default function MediaCard({ media, onChanged }) {
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="poster" style={posterUrl ? { backgroundImage: `url(${posterUrl})` } : {}}>
|
||||
<div
|
||||
className="poster"
|
||||
style={posterUrl ? { backgroundImage: `url(${posterUrl})`, cursor: 'pointer' } : { cursor: 'pointer' }}
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
{!posterUrl && 'Нет постера'}
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="title">{media.title} {media.year ? `(${media.year})` : ''}</div>
|
||||
<div className="title" style={{ cursor: 'pointer' }} onClick={() => setShowDetail(true)}>
|
||||
{media.title} {media.year ? `(${media.year})` : ''}
|
||||
</div>
|
||||
<div className="meta">
|
||||
<span className="badge">{TYPE_LABELS[media.type] || media.type}</span>
|
||||
</div>
|
||||
{media.rewatchSuggestion && (
|
||||
<div className="badge" style={{ borderColor: 'var(--accent)', color: 'var(--accent)' }}>
|
||||
Вы уже смотрели это раньше
|
||||
</div>
|
||||
)}
|
||||
<div className="ratings">
|
||||
{media.externalRating != null && <span className="r">TMDB {media.externalRating.toFixed(1)}</span>}
|
||||
{media.inheritedRating != null && <span className="r">Унаследованный {media.inheritedRating.toFixed(1)}</span>}
|
||||
{media.inheritedRating != null && media.inheritedRating > 0 && (
|
||||
<span className="r">Унаследованный {media.inheritedRating.toFixed(1)}</span>
|
||||
)}
|
||||
{media.relevanceScore != null && media.relevanceScore > 0 && <span className="r">Релевантность {media.relevanceScore.toFixed(1)}</span>}
|
||||
{media.intersectionCount > 0 && <span className="r">Найдено через: {media.intersectionCount}</span>}
|
||||
</div>
|
||||
<div className="rating-input">
|
||||
<label>Моя оценка</label>
|
||||
@@ -66,12 +92,18 @@ export default function MediaCard({ media, onChanged }) {
|
||||
<button className="primary" disabled={busy} onClick={() => setShowModal(true)}>
|
||||
Добавить в {media.type === 'SERIES' ? 'Sonarr' : 'Radarr'}
|
||||
</button>
|
||||
<button disabled={busy} onClick={snooze}>Не предлагать 3 мес.</button>
|
||||
{!media.rewatchSuggestion && (
|
||||
<button disabled={busy} onClick={markWatched}>Уже смотрел</button>
|
||||
)}
|
||||
<button disabled={busy} onClick={blacklist}>Не предлагать</button>
|
||||
</div>
|
||||
</div>
|
||||
{showModal && (
|
||||
<AddToLibraryModal media={media} onClose={() => setShowModal(false)} onAdded={onChanged} />
|
||||
)}
|
||||
{showDetail && (
|
||||
<MediaDetailModal media={media} onClose={() => setShowDetail(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function MediaDetailModal({ media, onClose }) {
|
||||
const posterUrl = media.posterPath
|
||||
? (media.posterPath.startsWith('http') ? media.posterPath : `https://image.tmdb.org/t/p/w300${media.posterPath}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{media.title} {media.year ? `(${media.year})` : ''}</h3>
|
||||
{posterUrl && (
|
||||
<img src={posterUrl} alt={media.title} style={{ width: '100%', borderRadius: 8, marginBottom: 12 }} />
|
||||
)}
|
||||
<p style={{ color: 'var(--text-dim)', fontSize: 13, lineHeight: 1.5 }}>
|
||||
{media.overview || 'Описание недоступно.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { api } from '../api/client.js';
|
||||
import MediaCard from '../components/MediaCard.jsx';
|
||||
|
||||
const TYPE_LABELS = { MOVIE: 'Фильм', SERIES: 'Сериал', CARTOON: 'Мультфильм' };
|
||||
|
||||
export default function Home() {
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [typeFilter, setTypeFilter] = useState('ALL');
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getRecommendations().then(setItems).catch((e) => setError(e.message));
|
||||
@@ -12,9 +15,25 @@ export default function Home() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return items;
|
||||
if (typeFilter === 'ALL') return items;
|
||||
return items.filter((m) => m.type === typeFilter);
|
||||
}, [items, typeFilter]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h2>Рекомендации</h2>
|
||||
{items && items.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<button className={typeFilter === 'ALL' ? 'primary' : ''} onClick={() => setTypeFilter('ALL')}>Все</button>
|
||||
{Object.entries(TYPE_LABELS).map(([key, label]) => (
|
||||
<button key={key} className={typeFilter === key ? 'primary' : ''} onClick={() => setTypeFilter(key)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="test-result fail">{error}</div>}
|
||||
{!items && !error && <p>Загрузка...</p>}
|
||||
{items && items.length === 0 && (
|
||||
@@ -22,9 +41,9 @@ export default function Home() {
|
||||
Пока нечего рекомендовать. Настройте интеграции в разделе «Настройки», затем нажмите «Пересчитать сейчас».
|
||||
</div>
|
||||
)}
|
||||
{items && items.length > 0 && (
|
||||
{filtered && filtered.length > 0 && (
|
||||
<div className="grid">
|
||||
{items.map((m) => (
|
||||
{filtered.map((m) => (
|
||||
<MediaCard key={m.id} media={m} onChanged={load} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../api/client.js';
|
||||
import MediaCard from '../components/MediaCard.jsx';
|
||||
|
||||
export default function WatchAgain() {
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getWatchAgain().then(setItems).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h2>Посмотреть ещё раз</h2>
|
||||
{error && <div className="test-result fail">{error}</div>}
|
||||
{!items && !error && <p>Загрузка...</p>}
|
||||
{items && items.length === 0 && (
|
||||
<div className="empty-state">Здесь пока пусто - тайтлы, удалённые из библиотеки после просмотра, появятся здесь.</div>
|
||||
)}
|
||||
{items && items.length > 0 && (
|
||||
<div className="grid">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} onChanged={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { api } from '../api/client.js';
|
||||
import LibraryCard from '../components/LibraryCard.jsx';
|
||||
|
||||
const SORTS = {
|
||||
title: (a, b) => (a.title || '').localeCompare(b.title || ''),
|
||||
year: (a, b) => (b.year ?? 0) - (a.year ?? 0),
|
||||
personalRating: (a, b) => (b.personalRating ?? -1) - (a.personalRating ?? -1),
|
||||
externalRating: (a, b) => (b.externalRating ?? -1) - (a.externalRating ?? -1)
|
||||
};
|
||||
|
||||
export default function Watched() {
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [includeHidden, setIncludeHidden] = useState(false);
|
||||
const [libraryFilter, setLibraryFilter] = useState('ALL'); // ALL | IN | OUT
|
||||
const [sortKey, setSortKey] = useState('title');
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getWatched(includeHidden).then(setItems).catch((e) => setError(e.message));
|
||||
}, [includeHidden]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return items;
|
||||
let list = items;
|
||||
if (libraryFilter === 'IN') list = list.filter((m) => m.inLibrary);
|
||||
if (libraryFilter === 'OUT') list = list.filter((m) => !m.inLibrary);
|
||||
return [...list].sort(SORTS[sortKey]);
|
||||
}, [items, libraryFilter, sortKey]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h2>Просмотрено</h2>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className={libraryFilter === 'ALL' ? 'primary' : ''} onClick={() => setLibraryFilter('ALL')}>Все</button>
|
||||
<button className={libraryFilter === 'IN' ? 'primary' : ''} onClick={() => setLibraryFilter('IN')}>В библиотеке</button>
|
||||
<button className={libraryFilter === 'OUT' ? 'primary' : ''} onClick={() => setLibraryFilter('OUT')}>Не в библиотеке</button>
|
||||
</div>
|
||||
<select value={sortKey} onChange={(e) => setSortKey(e.target.value)} style={{ width: 'auto' }}>
|
||||
<option value="title">По названию</option>
|
||||
<option value="year">По году</option>
|
||||
<option value="personalRating">По моей оценке</option>
|
||||
<option value="externalRating">По рейтингу TMDB</option>
|
||||
</select>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-dim)' }}>
|
||||
<input type="checkbox" checked={includeHidden} onChange={(e) => setIncludeHidden(e.target.checked)} style={{ width: 'auto' }} />
|
||||
Показать скрытые
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className="test-result fail">{error}</div>}
|
||||
{!items && !error && <p>Загрузка...</p>}
|
||||
{items && items.length === 0 && (
|
||||
<div className="empty-state">Пока пусто - здесь появится всё, что уже в библиотеке или было просмотрено.</div>
|
||||
)}
|
||||
{filtered && filtered.length > 0 && (
|
||||
<div className="grid">
|
||||
{filtered.map((m) => (
|
||||
<LibraryCard key={m.id} media={m} onChanged={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* Forwards any non-API, non-asset client-side route (e.g. /settings,
|
||||
* /watch-again) to index.html so React Router can handle it, since the
|
||||
* /watched) to index.html so React Router can handle it, since the
|
||||
* React app is served as static resources from this same Spring Boot jar.
|
||||
*/
|
||||
@Controller
|
||||
public class SpaRoutingConfig {
|
||||
|
||||
@GetMapping({"/", "/settings", "/watch-again", "/recommendations"})
|
||||
@GetMapping({"/", "/settings", "/watched", "/recommendations"})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import com.recommendarr.entity.MediaEntity;
|
||||
import com.recommendarr.entity.MediaType;
|
||||
import com.recommendarr.entity.PersonalRating;
|
||||
import com.recommendarr.entity.Snooze;
|
||||
import com.recommendarr.entity.WatchSignal;
|
||||
import com.recommendarr.repository.MediaEntityRepository;
|
||||
import com.recommendarr.repository.PersonalRatingRepository;
|
||||
import com.recommendarr.repository.SnoozeRepository;
|
||||
import com.recommendarr.repository.WatchSignalRepository;
|
||||
import com.recommendarr.service.RadarrClient;
|
||||
import com.recommendarr.service.SonarrClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -20,7 +22,7 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Per-card actions: rate, snooze, and add-to-library from the recommendation grid. */
|
||||
/** Per-card actions: rate, watched/blacklist/snooze, and add-to-library from the recommendation grid. */
|
||||
@RestController
|
||||
@RequestMapping("/api/media")
|
||||
public class MediaActionController {
|
||||
@@ -28,16 +30,19 @@ public class MediaActionController {
|
||||
private final MediaEntityRepository mediaRepository;
|
||||
private final PersonalRatingRepository personalRatingRepository;
|
||||
private final SnoozeRepository snoozeRepository;
|
||||
private final WatchSignalRepository watchSignalRepository;
|
||||
private final RadarrClient radarrClient;
|
||||
private final SonarrClient sonarrClient;
|
||||
|
||||
public MediaActionController(MediaEntityRepository mediaRepository,
|
||||
PersonalRatingRepository personalRatingRepository,
|
||||
SnoozeRepository snoozeRepository,
|
||||
WatchSignalRepository watchSignalRepository,
|
||||
RadarrClient radarrClient, SonarrClient sonarrClient) {
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.personalRatingRepository = personalRatingRepository;
|
||||
this.snoozeRepository = snoozeRepository;
|
||||
this.watchSignalRepository = watchSignalRepository;
|
||||
this.radarrClient = radarrClient;
|
||||
this.sonarrClient = sonarrClient;
|
||||
}
|
||||
@@ -60,6 +65,36 @@ public class MediaActionController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** "Уже смотрел" on a Recommendations card - just marks the WatchSignal, no status change. */
|
||||
@PostMapping("/{id}/watched")
|
||||
public ResponseEntity<Void> markWatched(@PathVariable Long id) {
|
||||
List<WatchSignal> existing = watchSignalRepository.findByMediaEntityId(id);
|
||||
WatchSignal signal = existing.isEmpty() ? new WatchSignal() : existing.get(0);
|
||||
signal.setMediaEntityId(id);
|
||||
signal.setWatched(true);
|
||||
watchSignalRepository.save(signal);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** Permanent "не предлагать" - replaces the temporary snooze on the Recommendations page. */
|
||||
@PostMapping("/{id}/blacklist")
|
||||
public ResponseEntity<Void> blacklist(@PathVariable Long id) {
|
||||
Snooze snooze = snoozeRepository.findByMediaEntityId(id).orElseGet(Snooze::new);
|
||||
snooze.setMediaEntityId(id);
|
||||
snooze.setBlacklisted(true);
|
||||
snoozeRepository.save(snooze);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unblacklist")
|
||||
public ResponseEntity<Void> unblacklist(@PathVariable Long id) {
|
||||
snoozeRepository.findByMediaEntityId(id).ifPresent(s -> {
|
||||
s.setBlacklisted(false);
|
||||
snoozeRepository.save(s);
|
||||
});
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** Live-fetched quality profiles / root folders for the add-to-library modal, keyed by media type. */
|
||||
@GetMapping("/{id}/add-options")
|
||||
public AddOptions addOptions(@PathVariable Long id) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.recommendarr.dto.MediaCardDto;
|
||||
import com.recommendarr.service.ScoringService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -18,15 +19,15 @@ public class RecommendationController {
|
||||
this.scoringService = scoringService;
|
||||
}
|
||||
|
||||
/** Home screen: found releases NOT in library and status NEVER_HAD, unsnoozed. */
|
||||
/** Home screen: fresh candidates, with occasional rewatch suggestions spliced in. */
|
||||
@GetMapping("/recommendations")
|
||||
public List<MediaCardDto> recommendations() {
|
||||
return scoringService.getRecommendations();
|
||||
}
|
||||
|
||||
/** "Watch Again" page: status REMOVED, unsnoozed. */
|
||||
@GetMapping("/watch-again")
|
||||
public List<MediaCardDto> watchAgain() {
|
||||
return scoringService.getWatchAgain();
|
||||
/** "Просмотрено" page: everything in the library and/or ever watched. */
|
||||
@GetMapping("/watched")
|
||||
public List<MediaCardDto> watched(@RequestParam(defaultValue = "false") boolean includeHidden) {
|
||||
return scoringService.getWatched(includeHidden);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,13 @@ public class MediaCardDto {
|
||||
private Double inheritedRating;
|
||||
private Double relevanceScore;
|
||||
private boolean snoozed;
|
||||
private String overview;
|
||||
private int intersectionCount;
|
||||
private boolean blacklisted;
|
||||
/** True when spliced into the Recommendations feed as an occasional rewatch suggestion. */
|
||||
private boolean rewatchSuggestion;
|
||||
/** For the "Просмотрено" page. */
|
||||
private boolean inLibrary;
|
||||
private boolean hasFile;
|
||||
private boolean watched;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.recommendarr.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@@ -64,6 +65,20 @@ public class MediaEntity {
|
||||
/** Cached TMDB vote_average, refreshed on each recompute. Used as external_rating. */
|
||||
private Double externalRating;
|
||||
|
||||
/** Plot synopsis, from TMDB or Radarr/Sonarr's own metadata. */
|
||||
@Column(columnDefinition = "text")
|
||||
private String overview;
|
||||
|
||||
/**
|
||||
* Whether the file has actually finished downloading (Radarr's hasFile /
|
||||
* Sonarr's episodeFileCount > 0). Purely informational for the
|
||||
* "Просмотрено" page badges - does NOT affect IN_LIBRARY status, which is
|
||||
* based on collection membership alone (see MediaService).
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean hasFile = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
|
||||
@@ -62,4 +62,13 @@ public class ScoringConfig {
|
||||
/** Max number of TMDB recommendations/similar results consumed per source. */
|
||||
@Column(nullable = false)
|
||||
private int maxResultsPerSource = 20;
|
||||
|
||||
/**
|
||||
* Every N regular items in the Recommendations feed, one rewatch
|
||||
* candidate (a title with WatchSignal.watched=true that isn't currently
|
||||
* in the library) is spliced in, flagged for the frontend's badge.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("8")
|
||||
private int rewatchInsertInterval = 8;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.recommendarr.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@@ -22,4 +23,14 @@ public class Snooze {
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant snoozedUntil = Instant.now().plus(90, ChronoUnit.DAYS);
|
||||
|
||||
/**
|
||||
* Permanent "don't suggest" flag, independent of snoozedUntil - set via
|
||||
* the Recommendations "Не предлагать" button (replaces the temporary
|
||||
* snooze there); reviewed/undone from the "Просмотрено" page's
|
||||
* "Показать скрытые" toggle.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean blacklisted = false;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ public class MediaService {
|
||||
media.setYear(year);
|
||||
media.setTmdbId(tmdbId > 0 ? tmdbId : media.getTmdbId());
|
||||
media.setPosterPath(poster);
|
||||
media.setOverview(radarrMovie.path("overview").asText(media.getOverview()));
|
||||
// hasFile is purely informational (badges on the "Просмотрено" page) -
|
||||
// it never affects IN_LIBRARY status, see comment above.
|
||||
media.setHasFile(radarrMovie.path("hasFile").asBoolean(false));
|
||||
media.setStatus(MediaStatus.IN_LIBRARY);
|
||||
return repository.save(media);
|
||||
}
|
||||
@@ -72,12 +76,16 @@ public class MediaService {
|
||||
media.setYear(year);
|
||||
media.setTmdbId(tmdbId > 0 ? tmdbId : media.getTmdbId());
|
||||
media.setPosterPath(poster);
|
||||
media.setOverview(sonarrSeries.path("overview").asText(media.getOverview()));
|
||||
// Sonarr has no flat "hasFile" like Radarr - use the episode file
|
||||
// count from its statistics block instead.
|
||||
media.setHasFile(sonarrSeries.path("statistics").path("episodeFileCount").asInt(0) > 0);
|
||||
media.setStatus(MediaStatus.IN_LIBRARY);
|
||||
return repository.save(media);
|
||||
}
|
||||
|
||||
public MediaEntity findOrCreateFromTmdb(long tmdbId, String title, Integer year, String posterPath,
|
||||
MediaType type) {
|
||||
MediaType type, String overview) {
|
||||
Optional<MediaEntity> existing = repository.findByTmdbId(tmdbId);
|
||||
if (existing.isPresent()) return existing.get();
|
||||
MediaEntity media = new MediaEntity();
|
||||
@@ -85,6 +93,7 @@ public class MediaService {
|
||||
media.setTitle(title);
|
||||
media.setYear(year);
|
||||
media.setPosterPath(posterPath);
|
||||
media.setOverview(overview);
|
||||
media.setType(type);
|
||||
media.setStatus(MediaStatus.NEVER_HAD);
|
||||
return repository.save(media);
|
||||
|
||||
@@ -77,9 +77,11 @@ public class RecomputeService {
|
||||
: (isAnimation ? MediaType.CARTOON : MediaType.MOVIE);
|
||||
|
||||
MediaEntity foundMedia = mediaService.findOrCreateFromTmdb(
|
||||
result.tmdbId(), result.title(), result.year(), result.posterPath(), foundType);
|
||||
if (result.voteAverage() != null) {
|
||||
foundMedia.setExternalRating(result.voteAverage());
|
||||
result.tmdbId(), result.title(), result.year(), result.posterPath(), foundType,
|
||||
result.overview());
|
||||
if (result.voteAverage() != null || result.overview() != null) {
|
||||
if (result.voteAverage() != null) foundMedia.setExternalRating(result.voteAverage());
|
||||
if (result.overview() != null) foundMedia.setOverview(result.overview());
|
||||
mediaRepository.save(foundMedia);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ public class ScoringConfigService {
|
||||
config.setInheritedRatingWeight(incoming.getInheritedRatingWeight());
|
||||
config.setExternalRatingWeight(incoming.getExternalRatingWeight());
|
||||
config.setRewatchAmplifier(incoming.getRewatchAmplifier());
|
||||
config.setWatchedBoost(incoming.getWatchedBoost());
|
||||
config.setDefaultRatingFloor(incoming.getDefaultRatingFloor());
|
||||
config.setMaxResultsPerSource(incoming.getMaxResultsPerSource());
|
||||
config.setRewatchInsertInterval(incoming.getRewatchInsertInterval());
|
||||
return repository.save(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,29 +40,130 @@ public class ScoringService {
|
||||
this.scoringConfigService = scoringConfigService;
|
||||
}
|
||||
|
||||
/** Main "Recommendations" feed: found releases NOT in library and NEVER_HAD, unsnoozed. */
|
||||
/**
|
||||
* Main "Recommendations" feed: fresh (never watched) NEVER_HAD candidates,
|
||||
* with an occasional rewatch suggestion (watched, not currently in the
|
||||
* library) spliced in every `rewatchInsertInterval` items so previously
|
||||
* watched titles surface without spamming the feed.
|
||||
*/
|
||||
public List<MediaCardDto> getRecommendations() {
|
||||
return score(mediaRepository.findByStatus(MediaStatus.NEVER_HAD), true);
|
||||
}
|
||||
|
||||
/** "Watch Again" feed: titles once in the library, since removed. */
|
||||
public List<MediaCardDto> getWatchAgain() {
|
||||
return score(mediaRepository.findByStatus(MediaStatus.REMOVED), true);
|
||||
}
|
||||
|
||||
private List<MediaCardDto> score(List<MediaEntity> candidates, boolean hideSnoozed) {
|
||||
ScoringConfig config = scoringConfigService.get();
|
||||
Set<Long> watchedIds = watchedIds();
|
||||
Set<Long> excludedIds = excludedIds();
|
||||
|
||||
Set<Long> snoozedIds = snoozeRepository.findAll().stream()
|
||||
.filter(s -> s.getSnoozedUntil().isAfter(Instant.now()))
|
||||
List<MediaEntity> primaryCandidates = mediaRepository.findByStatus(MediaStatus.NEVER_HAD).stream()
|
||||
.filter(m -> !watchedIds.contains(m.getId()) && !excludedIds.contains(m.getId()))
|
||||
.toList();
|
||||
|
||||
List<MediaEntity> rewatchCandidates = new ArrayList<>();
|
||||
rewatchCandidates.addAll(mediaRepository.findByStatus(MediaStatus.NEVER_HAD));
|
||||
rewatchCandidates.addAll(mediaRepository.findByStatus(MediaStatus.REMOVED));
|
||||
rewatchCandidates = rewatchCandidates.stream()
|
||||
.filter(m -> watchedIds.contains(m.getId()) && !excludedIds.contains(m.getId()))
|
||||
.toList();
|
||||
|
||||
List<MediaCardDto> primary = scoreList(primaryCandidates);
|
||||
List<MediaCardDto> rewatch = scoreList(rewatchCandidates);
|
||||
rewatch.forEach(dto -> dto.setRewatchSuggestion(true));
|
||||
|
||||
return interleave(primary, rewatch, config.getRewatchInsertInterval());
|
||||
}
|
||||
|
||||
/**
|
||||
* "Просмотрено" page: everything currently in the library (even if never
|
||||
* watched - it can't be re-added, so it still belongs here) plus
|
||||
* everything ever watched regardless of current status. `includeHidden`
|
||||
* additionally surfaces blacklisted titles so they can be un-blacklisted.
|
||||
*/
|
||||
public List<MediaCardDto> getWatched(boolean includeHidden) {
|
||||
Set<Long> watchedIds = watchedIds();
|
||||
Map<Long, MediaEntity> byId = new LinkedHashMap<>();
|
||||
for (MediaEntity m : mediaRepository.findByStatus(MediaStatus.IN_LIBRARY)) {
|
||||
byId.put(m.getId(), m);
|
||||
}
|
||||
if (!watchedIds.isEmpty()) {
|
||||
for (MediaEntity m : mediaRepository.findAllById(watchedIds)) {
|
||||
byId.putIfAbsent(m.getId(), m);
|
||||
}
|
||||
}
|
||||
Set<Long> blacklistedIds = blacklistedIds();
|
||||
if (includeHidden && !blacklistedIds.isEmpty()) {
|
||||
for (MediaEntity m : mediaRepository.findAllById(blacklistedIds)) {
|
||||
byId.putIfAbsent(m.getId(), m);
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaCardDto> result = new ArrayList<>();
|
||||
for (MediaEntity media : byId.values()) {
|
||||
MediaCardDto dto = new MediaCardDto();
|
||||
dto.setId(media.getId());
|
||||
dto.setTitle(media.getTitle());
|
||||
dto.setYear(media.getYear());
|
||||
dto.setType(media.getType());
|
||||
dto.setStatus(media.getStatus());
|
||||
dto.setTmdbId(media.getTmdbId());
|
||||
dto.setPosterPath(media.getPosterPath());
|
||||
dto.setOverview(media.getOverview());
|
||||
dto.setExternalRating(media.getExternalRating());
|
||||
personalRatingRepository.findByMediaEntityId(media.getId())
|
||||
.ifPresent(r -> dto.setPersonalRating(r.getRating()));
|
||||
dto.setInLibrary(media.getStatus() == MediaStatus.IN_LIBRARY);
|
||||
dto.setHasFile(media.isHasFile());
|
||||
dto.setWatched(watchedIds.contains(media.getId()));
|
||||
dto.setBlacklisted(blacklistedIds.contains(media.getId()));
|
||||
result.add(dto);
|
||||
}
|
||||
result.sort(Comparator.comparing(MediaCardDto::getTitle, Comparator.nullsLast(String::compareTo)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<Long> watchedIds() {
|
||||
return watchSignalRepository.findByWatchedTrue().stream()
|
||||
.map(WatchSignal::getMediaEntityId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<Long> blacklistedIds() {
|
||||
return snoozeRepository.findAll().stream()
|
||||
.filter(Snooze::isBlacklisted)
|
||||
.map(Snooze::getMediaEntityId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
if (hideSnoozed) {
|
||||
candidates = candidates.stream()
|
||||
.filter(m -> !snoozedIds.contains(m.getId()))
|
||||
.collect(Collectors.toList());
|
||||
private Set<Long> snoozedIds() {
|
||||
return snoozeRepository.findAll().stream()
|
||||
.filter(s -> s.getSnoozedUntil() != null && s.getSnoozedUntil().isAfter(Instant.now()))
|
||||
.map(Snooze::getMediaEntityId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** Union of blacklisted + temporarily-snoozed ids - always excluded from Recommendations. */
|
||||
private Set<Long> excludedIds() {
|
||||
Set<Long> excluded = new HashSet<>(blacklistedIds());
|
||||
excluded.addAll(snoozedIds());
|
||||
return excluded;
|
||||
}
|
||||
|
||||
/** Every `interval` items from `primary`, splice in one item from `extra` (round-robin) until it runs out. */
|
||||
private List<MediaCardDto> interleave(List<MediaCardDto> primary, List<MediaCardDto> extra, int interval) {
|
||||
if (extra.isEmpty() || interval <= 0) return primary;
|
||||
List<MediaCardDto> result = new ArrayList<>();
|
||||
int extraIndex = 0;
|
||||
for (int i = 0; i < primary.size(); i++) {
|
||||
result.add(primary.get(i));
|
||||
if ((i + 1) % interval == 0 && extraIndex < extra.size()) {
|
||||
result.add(extra.get(extraIndex++));
|
||||
}
|
||||
}
|
||||
// Any leftover primary-less remainder: append remaining rewatch suggestions at the end.
|
||||
while (extraIndex < extra.size()) {
|
||||
result.add(extra.get(extraIndex++));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<MediaCardDto> scoreList(List<MediaEntity> candidates) {
|
||||
ScoringConfig config = scoringConfigService.get();
|
||||
|
||||
// Average personal rating across ALL rated sources - used as the default
|
||||
// rating for a source that has none, per the spec's relevance_score rule.
|
||||
@@ -83,6 +184,8 @@ public class ScoringService {
|
||||
watchedBySource.merge(ws.getMediaEntityId(), ws.isWatched(), Boolean::logicalOr);
|
||||
}
|
||||
|
||||
Set<Long> snoozedIds = snoozedIds();
|
||||
|
||||
List<MediaCardDto> result = new ArrayList<>();
|
||||
for (MediaEntity media : candidates) {
|
||||
List<Intersection> intersections = intersectionRepository.findByFoundMediaEntityId(media.getId());
|
||||
@@ -116,10 +219,12 @@ public class ScoringService {
|
||||
dto.setStatus(media.getStatus());
|
||||
dto.setTmdbId(media.getTmdbId());
|
||||
dto.setPosterPath(media.getPosterPath());
|
||||
dto.setOverview(media.getOverview());
|
||||
dto.setPersonalRating(personal.map(PersonalRating::getRating).orElse(null));
|
||||
dto.setExternalRating(media.getExternalRating());
|
||||
dto.setInheritedRating(inheritedRatings.isEmpty() ? null : inherited);
|
||||
dto.setRelevanceScore(intersections.isEmpty() ? 0.0 : relevance);
|
||||
dto.setIntersectionCount(intersections.size());
|
||||
dto.setSnoozed(snoozedIds.contains(media.getId()));
|
||||
result.add(dto);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class TmdbClient {
|
||||
}
|
||||
|
||||
public record TmdbResult(Long tmdbId, String title, Integer year, String posterPath,
|
||||
Double voteAverage, List<String> genres) {
|
||||
Double voteAverage, List<String> genres, String overview) {
|
||||
}
|
||||
|
||||
/** movie or tv recommendations for a given TMDB id. */
|
||||
@@ -65,7 +65,7 @@ public class TmdbClient {
|
||||
IntegrationSetting setting = settingService.getOrCreate(IntegrationType.TMDB);
|
||||
String mediaPath = type == MediaType.SERIES ? "tv" : "movie";
|
||||
try {
|
||||
JsonNode node = client(setting).get().uri("/" + mediaPath + "/" + tmdbId)
|
||||
JsonNode node = client(setting).get().uri("/" + mediaPath + "/" + tmdbId + "?language=ru-RU")
|
||||
.retrieve().body(JsonNode.class);
|
||||
return node != null && node.has("vote_average") ? node.get("vote_average").asDouble() : null;
|
||||
} catch (Exception e) {
|
||||
@@ -82,7 +82,7 @@ public class TmdbClient {
|
||||
List<TmdbResult> results = new ArrayList<>();
|
||||
try {
|
||||
JsonNode response = client(setting).get()
|
||||
.uri("/" + mediaPath + "/" + tmdbId + "/" + endpoint)
|
||||
.uri("/" + mediaPath + "/" + tmdbId + "/" + endpoint + "?language=ru-RU")
|
||||
.retrieve().body(JsonNode.class);
|
||||
if (response == null || !response.has("results")) return results;
|
||||
for (JsonNode item : response.get("results")) {
|
||||
@@ -99,11 +99,12 @@ public class TmdbClient {
|
||||
}
|
||||
String poster = item.path("poster_path").asText(null);
|
||||
Double vote = item.has("vote_average") ? item.get("vote_average").asDouble() : null;
|
||||
String overview = item.path("overview").asText(null);
|
||||
List<String> genreIds = new ArrayList<>();
|
||||
if (item.has("genre_ids")) {
|
||||
item.get("genre_ids").forEach(g -> genreIds.add(g.asText()));
|
||||
}
|
||||
results.add(new TmdbResult(id, title, year, poster, vote, genreIds));
|
||||
results.add(new TmdbResult(id, title, year, poster, vote, genreIds, overview));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Swallow per-source TMDB failures so one bad lookup doesn't abort the whole recompute.
|
||||
|
||||
Reference in New Issue
Block a user