Add recompute progress bar in the UI header
Build and Deploy / build-and-deploy (push) Successful in 2m2s

RecomputeService now tracks running/processed/total sources and the last
run's new-intersection count via atomics (recompute() is already
synchronized to one run at a time, so plain shared state is enough). New
GET /api/recompute/status exposes it. The header polls this every second
while a recompute is in flight (whether triggered by the button or an
already-running scheduled job picked up on page load) and shows a progress
bar + "processed / total" counter instead of just a static "Пересчёт..."
label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kayashov.SM
2026-09-15 18:41:19 +04:00
co-authored by Claude Sonnet 5
parent 316d21341e
commit 6997dfb4e0
4 changed files with 112 additions and 11 deletions
+68 -9
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { NavLink, Route, Routes } from 'react-router-dom';
import Home from './pages/Home.jsx';
import Watched from './pages/Watched.jsx';
@@ -6,22 +6,62 @@ import Settings from './pages/Settings.jsx';
import { api } from './api/client.js';
export default function App() {
const [recomputing, setRecomputing] = useState(false);
const [progress, setProgress] = useState(null); // { running, processed, total, lastNewIntersections }
const [message, setMessage] = useState(null);
const pollRef = useRef(null);
function stopPolling() {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
function pollStatus() {
stopPolling();
pollRef.current = setInterval(async () => {
try {
const status = await api.getRecomputeStatus();
setProgress(status);
if (!status.running) {
stopPolling();
setMessage(`Пересчёт завершён: ${status.lastNewIntersections} новых пересечений найдено. Обновите страницу, чтобы увидеть результат.`);
}
} catch {
stopPolling();
}
}, 1000);
}
// Pick up an already-running recompute (e.g. the daily/weekly scheduled
// job) so the bar shows progress even if this page wasn't the one that
// triggered it.
useEffect(() => {
api.getRecomputeStatus().then((status) => {
if (status.running) {
setProgress(status);
pollStatus();
}
}).catch(() => {});
return stopPolling;
}, []);
async function handleRecompute() {
setRecomputing(true);
setMessage(null);
try {
await api.recompute();
setMessage('Пересчёт запущен - обновите страницу через некоторое время, чтобы увидеть новые результаты.');
setProgress({ running: true, processed: 0, total: 0, lastNewIntersections: 0 });
pollStatus();
} catch (e) {
setMessage('Не удалось запустить пересчёт: ' + e.message);
} finally {
setRecomputing(false);
}
}
const running = progress?.running;
const percent = running && progress.total > 0
? Math.min(100, Math.round((progress.processed / progress.total) * 100))
: null;
return (
<>
<header className="app-header">
@@ -31,10 +71,29 @@ export default function App() {
<NavLink to="/watched">Просмотрено</NavLink>
<NavLink to="/settings">Настройки</NavLink>
</nav>
<button className="primary" onClick={handleRecompute} disabled={recomputing}>
{recomputing ? 'Запуск...' : 'Пересчитать сейчас'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{running && (
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
{progress.total > 0 ? `${progress.processed} / ${progress.total} (${percent}%)` : 'Запуск...'}
</span>
)}
<button className="primary" onClick={handleRecompute} disabled={running}>
{running ? 'Пересчёт...' : 'Пересчитать сейчас'}
</button>
</div>
</header>
{running && (
<div style={{ height: 3, background: 'var(--bg-elevated)' }}>
<div
style={{
height: '100%',
background: 'var(--accent)',
width: percent != null ? `${percent}%` : '20%',
transition: 'width 0.3s ease'
}}
/>
</div>
)}
{message && <div style={{ padding: '8px 24px', color: '#9aa0a8' }}>{message}</div>}
<Routes>
<Route path="/" element={<Home />} />
+1
View File
@@ -33,6 +33,7 @@ export const api = {
}),
recompute: () => request('/api/recompute', { method: 'POST' }),
getRecomputeStatus: () => request('/api/recompute/status'),
getIntegrations: () => request('/api/settings/integrations'),
getIntegration: (type) => request(`/api/settings/integrations/${type}`),
@@ -2,6 +2,7 @@ package com.recommendarr.controller;
import com.recommendarr.service.RecomputeService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -9,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/** Manual "Recompute now" button in the UI header. */
/** Manual "Recompute now" button in the UI header, with a pollable progress endpoint. */
@RestController
@RequestMapping("/api/recompute")
public class RecomputeController {
@@ -28,4 +29,10 @@ public class RecomputeController {
CompletableFuture.runAsync(recomputeService::recompute);
return ResponseEntity.accepted().body(Map.of("status", "recompute started"));
}
/** Polled by the UI to drive the recompute progress bar. */
@GetMapping("/status")
public RecomputeService.Progress status() {
return recomputeService.progress();
}
}
@@ -8,6 +8,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
@@ -35,6 +37,21 @@ public class RecomputeService {
private final IntersectionRepository intersectionRepository;
private final ScoringConfigService scoringConfigService;
// Progress tracking for the "Recompute now" button's progress bar - a
// single global recompute run at a time (recompute() is synchronized),
// so plain shared fields are enough; no per-request state needed.
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicInteger totalSources = new AtomicInteger(0);
private final AtomicInteger processedSources = new AtomicInteger(0);
private volatile int lastNewIntersections = 0;
public record Progress(boolean running, int processed, int total, int lastNewIntersections) {
}
public Progress progress() {
return new Progress(running.get(), processedSources.get(), totalSources.get(), lastNewIntersections);
}
public RecomputeService(RadarrClient radarrClient, SonarrClient sonarrClient, TmdbClient tmdbClient,
MediaService mediaService, MediaEntityRepository mediaRepository,
IntersectionRepository intersectionRepository,
@@ -50,6 +67,17 @@ public class RecomputeService {
public synchronized void recompute() {
log.info("Starting recommendation recompute");
running.set(true);
processedSources.set(0);
totalSources.set(0);
try {
doRecompute();
} finally {
running.set(false);
}
}
private void doRecompute() {
syncLibraries();
// Sources = everything ever added to Radarr/Sonarr, whether still
@@ -58,11 +86,15 @@ public class RecomputeService {
// membership requirement.
Set<MediaEntity> sources = new LinkedHashSet<>(
mediaRepository.findByStatusIn(List.of(MediaStatus.IN_LIBRARY, MediaStatus.REMOVED)));
totalSources.set(sources.size());
int maxResults = scoringConfigService.get().getMaxResultsPerSource();
int newIntersections = 0;
for (MediaEntity source : sources) {
if (source.getTmdbId() == null || source.getTmdbId() <= 0) continue;
if (source.getTmdbId() == null || source.getTmdbId() <= 0) {
processedSources.incrementAndGet();
continue;
}
List<TmdbClient.TmdbResult> found = new ArrayList<>();
found.addAll(tmdbClient.recommendations(source.getTmdbId(), source.getType()));
found.addAll(tmdbClient.similar(source.getTmdbId(), source.getType()));
@@ -109,7 +141,9 @@ public class RecomputeService {
}
count++;
}
processedSources.incrementAndGet();
}
lastNewIntersections = newIntersections;
log.info("Recompute finished: {} sources processed, {} new intersections recorded",
sources.size(), newIntersections);
}