Add watched/blacklist actions, Просмотрено page, rewatch insertion, overview modal
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:
Kayashov.SM
2026-09-15 12:04:41 +04:00
co-authored by Claude Sonnet 5
parent e31bc87c4f
commit 7dc2a4dbb6
20 changed files with 478 additions and 77 deletions
+3 -3
View File
@@ -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>
</>
+4 -1
View File
@@ -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',
+89
View File
@@ -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>
);
}
+38 -6
View File
@@ -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>
);
}
+22 -3
View File
@@ -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>
-32
View File
@@ -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>
);
}
+67
View File
@@ -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.