Merge branch 'develop'
This commit is contained in:
@@ -24,6 +24,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned.
|
||||
- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style.
|
||||
- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup.
|
||||
- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability.
|
||||
- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download.
|
||||
- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
||||
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
||||
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
||||
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin)
|
||||
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%)
|
||||
|
||||
### History
|
||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||
|
||||
@@ -314,7 +314,7 @@ For each connected user the server:
|
||||
| See download/target paths | ✗ | ✓ |
|
||||
| See Sonarr/Radarr links | ✗ | ✓ |
|
||||
| View status panel | ✗ | ✓ |
|
||||
| Blocklist & search (import-pending) | ✗ | ✓ |
|
||||
| Blocklist & search | ✓ (when import issues OR torrent >1h old AND availability<100%) | ✓ (all downloads) |
|
||||
|
||||
### Tag Matching
|
||||
|
||||
@@ -415,6 +415,7 @@ Each matched download produces an object with:
|
||||
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
|
||||
| `importIssues` | string[] / null | Import warning/error messages |
|
||||
| `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) |
|
||||
| `canBlocklist` | boolean | `true` if the current user can blocklist this download (admin: always; non-admin: when import issues OR torrent >1h old AND availability<100%) |
|
||||
| `downloadPath` | string / null | (Admin) Download client path |
|
||||
| `targetPath` | string / null | (Admin) *arr target path |
|
||||
| `arrLink` | string / null | (Admin) Link to *arr web UI |
|
||||
@@ -424,6 +425,7 @@ Each matched download produces an object with:
|
||||
| `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance |
|
||||
| `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search |
|
||||
| `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command |
|
||||
| `addedOn` | number / null | (qBittorrent only) Unix timestamp when the torrent was added, used for age-based blocklist eligibility |
|
||||
|
||||
---
|
||||
|
||||
@@ -604,7 +606,9 @@ Admin-only per-user download counts (fetches live from APIs, not cached).
|
||||
|
||||
### `POST /api/dashboard/blocklist-search`
|
||||
|
||||
Admin-only. Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command.
|
||||
Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command.
|
||||
|
||||
**Access:** Admin users can blocklist any download. Non-admin users can only blocklist downloads that meet specific eligibility criteria: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. The frontend only shows the button when the user is eligible.
|
||||
|
||||
Requires CSRF token (`X-CSRF-Token` header).
|
||||
|
||||
@@ -633,7 +637,7 @@ Requires CSRF token (`X-CSRF-Token` header).
|
||||
|
||||
**Response (400):** Missing or invalid fields.
|
||||
|
||||
**Response (403):** Non-admin user.
|
||||
**Response (403):** Non-admin user attempting to blocklist without meeting eligibility criteria (no import issues and not an eligible torrent).
|
||||
|
||||
**Response (502):** Upstream *arr call failed.
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -554,15 +554,15 @@ function createDownloadCard(download) {
|
||||
issueBadge.textContent = 'Import Pending';
|
||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||
header.appendChild(issueBadge);
|
||||
}
|
||||
|
||||
if (isAdmin && download.arrQueueId) {
|
||||
const blBtn = document.createElement('button');
|
||||
blBtn.className = 'blocklist-search-btn';
|
||||
blBtn.textContent = '⛔ Blocklist & Search';
|
||||
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
if ((isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||
const blBtn = document.createElement('button');
|
||||
blBtn.className = 'blocklist-search-btn';
|
||||
blBtn.textContent = '⛔ Blocklist & Search';
|
||||
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
|
||||
@@ -94,6 +94,23 @@ function getRadarrLink(movie) {
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Determine if a download can be blocklisted by the current user
|
||||
// Admins: always true (they have arrQueueId)
|
||||
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
||||
function canBlocklist(download, isAdmin) {
|
||||
if (isAdmin) return true;
|
||||
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
|
||||
const addedOn = new Date(download.addedOn).getTime();
|
||||
const isOldEnough = addedOn < oneHourAgo;
|
||||
const availability = parseFloat(download.availability);
|
||||
const isLowAvailability = availability < 100;
|
||||
return isOldEnough && isLowAvailability;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr queue/history record.
|
||||
// Returns { season, episode, title } or null if data is missing.
|
||||
function extractEpisode(record) {
|
||||
@@ -321,15 +338,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = getSonarrLink(series);
|
||||
if (issues) {
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -371,15 +387,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = getRadarrLink(movie);
|
||||
if (issues) {
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -536,15 +551,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = getSonarrLink(series);
|
||||
if (sonarrIssues) {
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
@@ -579,15 +593,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = getRadarrLink(movie);
|
||||
if (radarrIssues) {
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
@@ -925,7 +938,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); if (issues) { dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } }
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -944,7 +958,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); if (issues) { dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } }
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -1011,7 +1026,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); if (issues) { download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } }
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; }
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
@@ -1027,7 +1043,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); if (issues) { download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } }
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; }
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ function mapTorrentToDownload(torrent) {
|
||||
category: torrent.category,
|
||||
tags: torrent.tags,
|
||||
savePath: torrent.content_path || torrent.save_path || null,
|
||||
addedOn: torrent.added_on || null,
|
||||
qbittorrent: true
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user