Skip to content

Commit 91e4b19

Browse files
authored
Merge pull request #35 from Cruzadera/develop
Release 1.5
2 parents f775206 + aa3c709 commit 91e4b19

18 files changed

Lines changed: 722 additions & 5 deletions

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,55 @@ Returns frontend settings, feature flags, destination shelves, and available ind
269269

270270
Persists frontend-managed settings.
271271

272+
The settings payload accepts a new optional field to control indexer prioritization:
273+
274+
```json
275+
{
276+
"filters": {
277+
"preferredFormat": "epub",
278+
"indexers": ["Indexer A", "Indexer B"],
279+
"indexerPriority": ["Indexer B", "Indexer A"]
280+
}
281+
}
282+
```
283+
284+
`filters.indexerPriority` is an ordered array of indexer names (strings). When present, Bookseerr will give a bonus to search results that originate from earlier indexers in the list.
285+
286+
### Scoring & Indexer priority
287+
288+
Bookseerr now uses a scoring-based ranking for search results to provide more consistent and relevant "best" selections. Factors included in the score:
289+
290+
- Preferred format: large bonus when the result matches `filters.preferredFormat`.
291+
- Format bias: EPUB/azw3/mobi receive positive weight; PDF receives a negative penalty by default.
292+
- Seeders: added as points (capped) so healthier torrents rank higher.
293+
- File size: smaller files are preferred with a small bonus (to avoid unnecessarily large downloads).
294+
- Language match: bonus when the result language matches `filters.language`.
295+
- Indexer priority: optional ordered bonus via `filters.indexerPriority` (earlier indexers get a larger bonus).
296+
297+
The `/api/search` response includes a numeric `score` on each result so you can inspect why a result was chosen. The search pipeline still applies filters (excluded formats, min seeds, max size, indexer selection) and falls back through relaxed stages if no result matches strictly.
298+
299+
### How to test locally
300+
301+
1. Open the UI and go to Settings → Prowlarr indexers. Use the new "Indexer priority" panel to add and order indexers.
302+
2. Save settings (this issues `POST /api/settings`).
303+
3. Run a search and inspect the returned results with `curl`:
304+
305+
```bash
306+
curl "http://localhost:3000/api/search?query=your+book" | jq .
307+
```
308+
309+
Look at the `score`, `format`, `seeders`, `sizeMB`, and `indexer` fields to verify ranking behaviour. Adjust `indexerPriority` or format preferences and save again to retune.
310+
311+
Example `POST /api/settings` body including priority (useful for testing via `curl`):
312+
313+
```bash
314+
curl -X POST http://localhost:3000/api/settings \
315+
-H "Content-Type: application/json" \
316+
-d '{"filters":{"preferredFormat":"epub","indexers":["MyIdx"],"indexerPriority":["MyIdx"]}}'
317+
```
318+
319+
This feature is intended to make "best result" selection more deterministic and customizable for multi-indexer setups. If you want different weighting (e.g., reduce format bias or change size/seed influence), we can expose weight settings in the UI or tune defaults in `src/services/prowlarr.service.js`.
320+
272321
### `GET /api/jobs`
273322

274323
Returns tracked jobs.

frontend/src/App.jsx

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SearchView from "./components/SearchView";
44
import SettingsView from "./components/SettingsView";
55
import Sidebar from "./components/Sidebar";
66
import JobsView from "./components/JobsView";
7+
import FavoritesView from "./components/FavoritesView";
78
import { useI18n } from "./hooks/useI18n";
89
import {
910
cloneSettings,
@@ -147,6 +148,8 @@ export default function App() {
147148
const [downloadingKey, setDownloadingKey] = useState("");
148149
const [jobs, setJobs] = useState([]);
149150
const [jobActionId, setJobActionId] = useState("");
151+
const [favorites, setFavorites] = useState([]);
152+
const [favoriteActionKey, setFavoriteActionKey] = useState("");
150153
const [destinationShelfEnabled, setDestinationShelfEnabled] = useState(false);
151154
const [destinationShelves, setDestinationShelves] = useState([]);
152155
const [selectedDestinationId, setSelectedDestinationId] = useState("");
@@ -213,6 +216,27 @@ export default function App() {
213216
};
214217
}, []);
215218

219+
useEffect(() => {
220+
let cancelled = false;
221+
222+
async function loadFavorites() {
223+
try {
224+
const response = await fetch("/api/favorites");
225+
const data = await handleJsonResponse(response);
226+
227+
if (!cancelled) {
228+
setFavorites(Array.isArray(data.favorites) ? data.favorites : []);
229+
}
230+
} catch {}
231+
}
232+
233+
loadFavorites();
234+
235+
return () => {
236+
cancelled = true;
237+
};
238+
}, []);
239+
216240
useEffect(() => {
217241
let cancelled = false;
218242

@@ -329,6 +353,97 @@ export default function App() {
329353
setJobs(Array.isArray(data.jobs) ? data.jobs : []);
330354
}
331355

356+
async function refreshFavorites() {
357+
const response = await fetch("/api/favorites");
358+
const data = await handleJsonResponse(response);
359+
setFavorites(Array.isArray(data.favorites) ? data.favorites : []);
360+
}
361+
362+
function getResultKey(item) {
363+
return `${item.title}::${item.downloadUrl}`;
364+
}
365+
366+
function getFavoriteForResult(item) {
367+
return favorites.find((favorite) => favorite.downloadUrl === item.downloadUrl) || null;
368+
}
369+
370+
async function addFavorite(item) {
371+
const resultKey = getResultKey(item);
372+
setFavoriteActionKey(resultKey);
373+
374+
try {
375+
const response = await fetch("/api/favorites", {
376+
method: "POST",
377+
headers: {
378+
"Content-Type": "application/json",
379+
},
380+
body: JSON.stringify({
381+
title: item.title,
382+
author: item.author,
383+
downloadUrl: item.downloadUrl,
384+
protocol: item.protocol || "torrent",
385+
format: item.format,
386+
sizeMB: item.sizeMB,
387+
seeders: item.seeders,
388+
publishDate: item.publishDate,
389+
indexer: item.indexer,
390+
language: item.language,
391+
coverUrl: item.coverUrl,
392+
}),
393+
});
394+
395+
await handleJsonResponse(response);
396+
await refreshFavorites();
397+
setHomeStatus({ message: t("ui.favorites.added"), error: false });
398+
} catch (error) {
399+
setHomeStatus({ message: error.message, error: true });
400+
} finally {
401+
setFavoriteActionKey("");
402+
}
403+
}
404+
405+
async function removeFavoriteById(favoriteId, statusMessage = "") {
406+
try {
407+
const response = await fetch(`/api/favorites/${favoriteId}`, {
408+
method: "DELETE",
409+
});
410+
411+
if (!response.ok && response.status !== 404) {
412+
await handleJsonResponse(response);
413+
}
414+
415+
await refreshFavorites();
416+
if (statusMessage) {
417+
setHomeStatus({ message: statusMessage, error: false });
418+
}
419+
} catch (error) {
420+
setHomeStatus({ message: error.message, error: true });
421+
}
422+
}
423+
424+
async function toggleFavorite(item) {
425+
const existing = getFavoriteForResult(item);
426+
427+
if (existing?.id) {
428+
setFavoriteActionKey(getResultKey(item));
429+
await removeFavoriteById(existing.id, t("ui.favorites.removed"));
430+
setFavoriteActionKey("");
431+
return;
432+
}
433+
434+
await addFavorite(item);
435+
}
436+
437+
async function removeFavoriteFromList(item) {
438+
if (!item?.id) {
439+
return;
440+
}
441+
442+
setFavoriteActionKey(item.id);
443+
await removeFavoriteById(item.id, t("ui.favorites.removed"));
444+
setFavoriteActionKey("");
445+
}
446+
332447
async function downloadBook(item) {
333448
const startedAt = Date.now();
334449
const resultKey = `${item.title}::${item.downloadUrl}`;
@@ -395,6 +510,53 @@ export default function App() {
395510
await downloadBook(best);
396511
}
397512

513+
// Local mirror of server-side filter enforcement to ensure auto-download
514+
// only triggers when a result strictly matches the user's settings.
515+
function matchesFiltersLocal(item, filters) {
516+
if (!item || !item.downloadUrl) return false;
517+
518+
const excluded = Array.isArray(filters.excludedFormats) ? filters.excludedFormats : [];
519+
if (excluded.includes((item.format || "").toLowerCase())) return false;
520+
521+
const minSeeds = Number(filters.minSeeds || 0);
522+
if ((Number(item.seeders || 0)) < minSeeds) return false;
523+
524+
const maxSize = Number(filters.maxSizeMB || 0);
525+
if (maxSize > 0 && Number(item.sizeMB || 0) > maxSize) return false;
526+
527+
if (filters.language && filters.language !== "any" && item.language && item.language !== "any") {
528+
if (item.language !== filters.language) return false;
529+
}
530+
531+
if (Array.isArray(filters.indexers) && filters.indexers.length) {
532+
const normalized = `${item.indexer || ""}`.trim().toLowerCase();
533+
const allowed = filters.indexers.map((i) => `${i || ""}`.trim().toLowerCase());
534+
if (!allowed.includes(normalized)) return false;
535+
}
536+
537+
return true;
538+
}
539+
540+
async function maybeAutoDownload(nextResults) {
541+
if (!settings.download.autoDownload || !nextResults.length) {
542+
return;
543+
}
544+
545+
const preferredFormat = settings.filters.preferredFormat || "any";
546+
const mustMatchPreferred = Boolean(settings.download.onlyIfPreferredFormat) && preferredFormat !== "any";
547+
548+
// Find a result that strictly matches the user's settings
549+
const candidate = nextResults.find((item) => {
550+
if (!matchesFiltersLocal(item, settings.filters)) return false;
551+
if (mustMatchPreferred && (item.format || "").toLowerCase() !== preferredFormat) return false;
552+
return true;
553+
});
554+
555+
if (!candidate) return;
556+
557+
await downloadBook(candidate);
558+
}
559+
398560
function saveRecentSearch(searchQuery) {
399561
const normalizedSearchQuery = normalizeRecentSearchEntry(searchQuery);
400562

@@ -637,10 +799,24 @@ export default function App() {
637799
searchError={searchError}
638800
resultErrors={resultErrors}
639801
downloadingKey={downloadingKey}
802+
favoriteActionKey={favoriteActionKey}
803+
favoriteIdsByResultKey={favorites.reduce((acc, favorite) => {
804+
acc[favorite.downloadUrl] = favorite.id;
805+
return acc;
806+
}, {})}
640807
isBusy={isBusy}
641808
onDownload={downloadBook}
809+
onToggleFavorite={toggleFavorite}
642810
onRetryDownload={downloadBook}
643811
/>
812+
) : activePage === "favorites" ? (
813+
<FavoritesView
814+
t={t}
815+
favorites={favorites}
816+
isBusy={isBusy || Boolean(favoriteActionKey)}
817+
onDownload={downloadBook}
818+
onRemove={removeFavoriteFromList}
819+
/>
644820
) : activePage === "jobs" ? (
645821
<JobsView
646822
t={t}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
function formatSize(sizeMB) {
2+
const numeric = Number(sizeMB || 0);
3+
4+
if (!Number.isFinite(numeric) || numeric <= 0) {
5+
return "-";
6+
}
7+
8+
return `${numeric >= 10 ? numeric.toFixed(0) : numeric.toFixed(1)} MB`;
9+
}
10+
11+
function formatPublishYear(value) {
12+
if (!value) {
13+
return "";
14+
}
15+
16+
const date = new Date(value);
17+
return Number.isNaN(date.getTime()) ? "" : `${date.getFullYear()}`;
18+
}
19+
20+
function EmptyFavorites({ t }) {
21+
return (
22+
<div className="state-panel state-panel--empty">
23+
<div className="state-panel-copy">
24+
<p className="state-panel-kicker">{t("ui.favorites.emptyKicker")}</p>
25+
<h2>{t("ui.favorites.emptyTitle")}</h2>
26+
<p>{t("ui.favorites.emptyDescription")}</p>
27+
</div>
28+
</div>
29+
);
30+
}
31+
32+
export default function FavoritesView({
33+
t,
34+
favorites,
35+
isBusy,
36+
onDownload,
37+
onRemove,
38+
}) {
39+
return (
40+
<section className="page-view">
41+
<section className="hero">
42+
<h1 className="hero-title">Bookseerr</h1>
43+
<p className="hero-subtitle">{t("ui.favorites.pageSubtitle")}</p>
44+
</section>
45+
46+
<section className="panel">
47+
{!favorites.length ? (
48+
<EmptyFavorites t={t} />
49+
) : (
50+
<div className="results favorites-results">
51+
{favorites.map((item) => {
52+
const year = formatPublishYear(item.publishDate);
53+
54+
return (
55+
<article key={item.id} className="result-card">
56+
<div className="result-cover">
57+
{item.coverUrl ? (
58+
<img src={item.coverUrl} alt={item.title || "Book"} loading="lazy" />
59+
) : (
60+
<span className="result-cover-fallback" aria-hidden="true">
61+
{(item.title?.charAt(0) || "?").toUpperCase()}
62+
</span>
63+
)}
64+
<span className="result-cover-format">
65+
{`${item.format || "unknown"}`.toUpperCase()}
66+
</span>
67+
</div>
68+
69+
<div className="result-main">
70+
<div className="result-head">
71+
<div className="result-copy">
72+
<h2>{item.title || "Untitled"}</h2>
73+
<p className="result-author">{item.author || t("ui.unknownAuthor")}</p>
74+
</div>
75+
76+
<div className="result-actions">
77+
<button type="button" disabled={isBusy} onClick={() => onDownload(item)}>
78+
{t("ui.favorites.downloadNow")}
79+
</button>
80+
<button
81+
type="button"
82+
className="secondary"
83+
disabled={isBusy}
84+
onClick={() => onRemove(item)}
85+
>
86+
{t("ui.favorites.remove")}
87+
</button>
88+
</div>
89+
</div>
90+
91+
<div className="result-submeta">
92+
<span>{item.indexer || t("ui.indexer")}</span>
93+
{year ? <span>{year}</span> : null}
94+
</div>
95+
96+
<dl className="meta meta-pills">
97+
<div>
98+
<dt>{t("ui.format")}</dt>
99+
<dd>{`${item.format || "unknown"}`.toUpperCase()}</dd>
100+
</div>
101+
<div>
102+
<dt>{t("ui.seeders")}</dt>
103+
<dd>{Number(item.seeders || 0)}</dd>
104+
</div>
105+
<div>
106+
<dt>{t("ui.size")}</dt>
107+
<dd>{formatSize(item.sizeMB)}</dd>
108+
</div>
109+
</dl>
110+
</div>
111+
</article>
112+
);
113+
})}
114+
</div>
115+
)}
116+
</section>
117+
</section>
118+
);
119+
}

0 commit comments

Comments
 (0)