Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498eabc7bc | ||
|
|
6b73727d4e | ||
|
|
593ad79670 | ||
|
|
b4a9d7187b | ||
|
|
691d101e56 | ||
|
|
6f2901b08c | ||
|
|
a4af16064b | ||
|
|
d6907f42d3 | ||
|
|
aec04474be | ||
|
|
f5315e5ceb | ||
|
|
13f3d767c5 | ||
|
|
a37874c553 | ||
|
|
5933e09652 |
No files matched your search
+38
-1
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.32] - YYYY-MM-DD
|
||||
|
||||
### Fixed
|
||||
|
||||
- **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64).
|
||||
- **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: <n>` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61).
|
||||
- **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62).
|
||||
|
||||
## [1.7.31] - 2026-05-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
|
||||
|
||||
## [1.7.30] - 2026-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||
|
||||
## [1.7.29] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
|
||||
|
||||
## [1.7.28] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||
|
||||
## [1.7.27] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
@@ -14,7 +51,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Ombi & *Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and *Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||
|
||||
## [1.7.25] - 2026-05-27
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy application source owned by root (read-only at runtime)
|
||||
COPY --chown=root:root server/ ./server/
|
||||
COPY --chown=root:root public/ ./public/
|
||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
||||
COPY --from=client-build --chown=root:root /app/public/ ./public/
|
||||
COPY --chown=root:root package.json ./
|
||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -194,7 +194,7 @@ function createRequestCard(request) {
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'service-icons-container';
|
||||
|
||||
const id = request.theTvDbId || request.theMovieDbId || request.theTvdbId || request.theTmdbId || request.TvDbId || request.TheTvDbId || request.imdbId || request.ImdbId;
|
||||
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||
if (state.ombiBaseUrl && id) {
|
||||
const ombiLink = document.createElement('a');
|
||||
ombiLink.className = 'ombi-link';
|
||||
|
||||
+36
-24
@@ -1,28 +1,40 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../public',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env variables from root directory to match backend TLS configuration
|
||||
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||
|
||||
const port = env.PORT || 3001;
|
||||
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||
|
||||
return {
|
||||
build: {
|
||||
outDir: '../public',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true, // Listen on all network interfaces
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: target,
|
||||
changeOrigin: true,
|
||||
secure: false // Allow self-signed certificate in development
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.27",
|
||||
"version": "1.7.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.27",
|
||||
"version": "1.7.31",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+5
-2
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.27",
|
||||
"version": "1.7.31",
|
||||
"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": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"dev:server": "nodemon server/index.js",
|
||||
"dev:client": "npm run dev --prefix client",
|
||||
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||
"start": "node server/index.js",
|
||||
"build": "npm run build --prefix client",
|
||||
"install:all": "npm install",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
+15
-15
@@ -1,11 +1,11 @@
|
||||
const o={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,ombiBaseUrl:null,ombiRequests:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},ombiWebhook:{enabled:!1,triggers:{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},stats:null},webhookMetrics:null,selectedRequestTypes:["movie","tv"],selectedRequestStatuses:[],requestSortMode:"requestedDate_desc",requestSearchQuery:""},Fe=1200,$e=5*60*1e3,Me=5e3;async function Ue(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),s=await e.json(),n=await t.json();return n.csrfToken&&(o.csrfToken=n.csrfToken),s.authenticated?(o.currentUser=s.user,o.isAdmin=!!s.user.isAdmin,{authenticated:!0,user:s.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function We(e,t,s){try{const a=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:s})})).json();return a.success?(o.currentUser=a.user,o.isAdmin=!!a.user.isAdmin,a.csrfToken&&(o.csrfToken=a.csrfToken),{success:!0,user:a.user}):{success:!1,error:a.error||"Login failed"}}catch(n){return console.error(n),{success:!1,error:"Login failed. Please try again."}}}async function He(){try{return await fetch("/api/auth/logout",{method:"POST",headers:o.csrfToken?{"X-CSRF-Token":o.csrfToken}:{}}),o.currentUser=null,o.csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function Oe(e=!1){try{const t=new URLSearchParams({days:o.historyDays});o.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const s=await fetch(`/api/history/recent?${t}`);if(!s.ok)throw new Error(`HTTP ${s.status}`);return{success:!0,history:(await s.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function Pe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":o.csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentIds:e.arrContentIds,arrSeriesId:e.arrSeriesId,arrContentType:e.arrContentType})});if(!t.ok){const s=await t.json().catch(()=>({}));throw new Error(s.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function je(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function _e(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function q(){var e,t;try{const s=_e();let n=!1;try{const h=await fetch("/api/webhook/config");h.ok&&(n=(await h.json()).valid||!1)}catch{}let a=!1,i={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/sonarr/notifications");if(h.ok){const b=(await h.json()).find(v=>v.name==="Sofarr");a=n&&!!b,b&&(i={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let l=!1,m={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/radarr/notifications");if(h.ok){const b=(await h.json()).find(v=>v.name==="Sofarr");l=n&&!!b,b&&(m={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let u=!1,c={requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=null;try{const h=await fetch("/api/ombi/webhook/status");if(h.ok){const y=await h.json();u=y.enabled||!1,c=y.triggers||{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=y.stats||null}}catch{}o.webhookMetrics=await s;const r=o.webhookMetrics?Object.entries(o.webhookMetrics.instances||{}):[],d=((e=r.find(([h])=>h.includes("sonarr")))==null?void 0:e[1])||null,p=((t=r.find(([h])=>h.includes("radarr")))==null?void 0:t[1])||null;return o.sonarrWebhook={enabled:a,triggers:i,stats:d},o.radarrWebhook={enabled:l,triggers:m,stats:p},o.ombiWebhook={enabled:u,triggers:c,stats:g},{success:!0}}catch(s){return console.error("Failed to fetch webhook status:",s),{success:!1}}}async function Ge(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}asynLine truncated
|
||||
`))}return t}function P(e,t,s){const n=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(l=>!l.matchedUser),i=e.filter(l=>l.matchedUser);for(const l of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=l.label,n.appendChild(m)}for(const l of i){const m=document.createElement("span");m.className="download-user-badge",m.textContent=l.matchedUser,n.appendChild(m)}}else if(s){const a=document.createElement("span");a.className="download-user-badge",a.textContent=s,n.appendChild(a)}return n}function fe(e){const t=document.createElement("span");t.className="download-client-logo-wrapper download-card-logo-wrapper";const s=document.createElement("img");return s.className="download-client-logo",s.src=`/images/clients/${e.client}.svg`,s.alt=`${e.instanceName||e.client} icon`,s.title=e.instanceName||e.client,s.onerror=()=>{t.textContent=e.client.charAt(0).toUpperCase(),t.classList.add("fallback")},t.appendChild(s),t}function oe(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi",s.href=e.ombiLink;const n=document.createElement("a");n.href=e.ombiLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}if(o.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const n=document.createElement("a");n.href=e.arrLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}return t}function j(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let s=o.downloads;if(o.selectedDownloadClients.length>0){const i=o.selectedDownloadClients.map(l=>o.downloadClients[l]).filter(Boolean);s=o.downloads.filter(l=>i.some(m=>m.type===l.client&&m.id===l.instanceId))}if(o.downloadClients.length>0){const i=new Map(o.downloadClients.map((l,m)=>[l.id,m]));s=[...s].sort((l,m)=>{const u=i.get(l.instanceId)??1/0,c=i.get(m.instanceId)??1/0;return u-c})}if(s.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const n=new Map;e.querySelectorAll(".download-card").forEach(i=>{n.set(i.dataset.id,i)});const a=new Set;s.forEach(i=>{const l=i.title;a.add(l);const m=n.get(l);if(m)et(m,i);else{const u=st(i);e.appendChild(u)}}),n.forEach((i,l)=>{a.has(l)||i.remove()})}function et(e,t){const s=e.querySelector(".download-header-right");s&&s.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(r=>r.remove());const a=e.querySelector(".download-header .download-client-logo-wrapper");a&&a.remove();const i=e.querySelector(".download-card-logo-wrapper");i&&i.remove();const l=e.querySelector(".download-header");if(l&&!l.querySelector(".download-header-right")){const r=document.createElement("div");r.className="download-header-right";const d=P(t.tagBadges,o.showAll,t.matchedUserTag);r.appendChild(d),l.appendChild(r)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(fe(t));const m=e.querySelector(".download-status");m&&m.textContent!==t.status&&(m.textContent=t.status,m.className=`download-status ${t.status}`);const u=e.querySelector(".progress-container");if(u&&t.progress!==void 0){const r=u.querySelector(".progress-bar"),d=u.querySelector(".progress-text"),p=u.querySelector(".missing-text");if(r){const h=r.querySelector(".downloaded");h&&(h.style.width=t.progress+"%")}if(d&&(d.textContent=t.progress+"%"),p){const h=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&h>0?p.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`:p.textContent=""}}const c=e.querySelector('.detail-item[data-label="Speed"] .detail-value');c&&t.speed!==void 0&&(c.textContent=he(t.speed));const g=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(g&&t.eta!==void 0&&(g.textContent=t.eta),t.qbittorrent){const r=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');r&&t.seeds!==void 0&&(r.textContent=t.seeds);const d=e.querySelector('.detail-item[data-label="Peers"] .detail-value');d&&t.peers!==void 0&&(d.textContent=t.peers);const p=e.querySelector('.detail-item[data-label="Availability"]');p&&t.availability!==void 0&&(p.querySelector(".detail-value").textContent=`${t.availability}%`,p.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function tt(e,t){if(console.log("[Blocklist] Clicked, download:",t),console.log("[Blocklist] Required fields:",{arrQueueId:t.arrQueueId,arrType:t.arrType,arrInstanceUrl:t.arrInstanceUrl,arrInstanceKey:t.arrInstanceKey,arrContentId:t.arrContentId,arrContentIds:t.arrContentIds,arrSeriesId:t.arrSeriesId,arrContentType:t.arrContentType,isAdmin:o.isAdmin,canBlocklist:t.canBl
Line truncated
|
||||
const o={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,ombiBaseUrl:null,ombiRequests:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},ombiWebhook:{enabled:!1,triggers:{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},stats:null},webhookMetrics:null,selectedRequestTypes:["movie","tv"],selectedRequestStatuses:[],requestSortMode:"requestedDate_desc",requestSearchQuery:""},Fe=1200,$e=5*60*1e3,Me=5e3;async function Ue(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),s=await e.json(),n=await t.json();return n.csrfToken&&(o.csrfToken=n.csrfToken),s.authenticated?(o.currentUser=s.user,o.isAdmin=!!s.user.isAdmin,{authenticated:!0,user:s.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function We(e,t,s){try{const a=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:s})})).json();return a.success?(o.currentUser=a.user,o.isAdmin=!!a.user.isAdmin,a.csrfToken&&(o.csrfToken=a.csrfToken),{success:!0,user:a.user}):{success:!1,error:a.error||"Login failed"}}catch(n){return console.error(n),{success:!1,error:"Login failed. Please try again."}}}async function He(){try{return await fetch("/api/auth/logout",{method:"POST",headers:o.csrfToken?{"X-CSRF-Token":o.csrfToken}:{}}),o.currentUser=null,o.csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function Oe(e=!1){try{const t=new URLSearchParams({days:o.historyDays});o.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const s=await fetch(`/api/history/recent?${t}`);if(!s.ok)throw new Error(`HTTP ${s.status}`);return{success:!0,history:(await s.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function Pe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":o.csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentIds:e.arrContentIds,arrSeriesId:e.arrSeriesId,arrContentType:e.arrContentType})});if(!t.ok){const s=await t.json().catch(()=>({}));throw new Error(s.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function je(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function _e(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function x(){var e,t;try{const s=_e();let n=!1;try{const d=await fetch("/api/webhook/config");d.ok&&(n=(await d.json()).valid||!1)}catch{}let a=!1,r={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const d=await fetch("/api/sonarr/notifications");if(d.ok){const b=(await d.json()).find(v=>v.name==="Sofarr");a=n&&!!b,b&&(r={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let l=!1,h={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const d=await fetch("/api/radarr/notifications");if(d.ok){const b=(await d.json()).find(v=>v.name==="Sofarr");l=n&&!!b,b&&(h={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let m=!1,u={requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=null;try{const d=await fetch("/api/ombi/webhook/status");if(d.ok){const y=await d.json();m=y.enabled||!1,u=y.triggers||{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=y.stats||null}}catch{}o.webhookMetrics=await s;const i=o.webhookMetrics?Object.entries(o.webhookMetrics.instances||{}):[],p=((e=i.find(([d])=>d.includes("sonarr")))==null?void 0:e[1])||null,c=((t=i.find(([d])=>d.includes("radarr")))==null?void 0:t[1])||null;return o.sonarrWebhook={enabled:a,triggers:r,stats:p},o.radarrWebhook={enabled:l,triggers:h,stats:c},o.ombiWebhook={enabled:m,triggers:u,stats:g},{success:!0}}catch(s){return console.error("Failed to fetch webhook status:",s),{success:!1}}}async function Ge(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Failed to enable");return await x(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}asynLine truncated
|
||||
`))}return t}function P(e,t,s){const n=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(l=>!l.matchedUser),r=e.filter(l=>l.matchedUser);for(const l of a){const h=document.createElement("span");h.className="download-user-badge unmatched",h.textContent=l.label,n.appendChild(h)}for(const l of r){const h=document.createElement("span");h.className="download-user-badge",h.textContent=l.matchedUser,n.appendChild(h)}}else if(s){const a=document.createElement("span");a.className="download-user-badge",a.textContent=s,n.appendChild(a)}return n}function fe(e){const t=document.createElement("span");t.className="download-client-logo-wrapper download-card-logo-wrapper";const s=document.createElement("img");return s.className="download-client-logo",s.src=`/images/clients/${e.client}.svg`,s.alt=`${e.instanceName||e.client} icon`,s.title=e.instanceName||e.client,s.onerror=()=>{t.textContent=e.client.charAt(0).toUpperCase(),t.classList.add("fallback")},t.appendChild(s),t}function oe(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi",s.href=e.ombiLink;const n=document.createElement("a");n.href=e.ombiLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}if(o.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const n=document.createElement("a");n.href=e.arrLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}return t}function j(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let s=o.downloads;if(o.selectedDownloadClients.length>0){const r=o.selectedDownloadClients.map(l=>o.downloadClients[l]).filter(Boolean);s=o.downloads.filter(l=>r.some(h=>h.type===l.client&&h.id===l.instanceId))}if(o.downloadClients.length>0){const r=new Map(o.downloadClients.map((l,h)=>[l.id,h]));s=[...s].sort((l,h)=>{const m=r.get(l.instanceId)??1/0,u=r.get(h.instanceId)??1/0;return m-u})}if(s.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const n=new Map;e.querySelectorAll(".download-card").forEach(r=>{n.set(r.dataset.id,r)});const a=new Set;s.forEach(r=>{const l=r.title;a.add(l);const h=n.get(l);if(h)et(h,r);else{const m=st(r);e.appendChild(m)}}),n.forEach((r,l)=>{a.has(l)||r.remove()})}function et(e,t){const s=e.querySelector(".download-header-right");s&&s.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(i=>i.remove());const a=e.querySelector(".download-header .download-client-logo-wrapper");a&&a.remove();const r=e.querySelector(".download-card-logo-wrapper");r&&r.remove();const l=e.querySelector(".download-header");if(l&&!l.querySelector(".download-header-right")){const i=document.createElement("div");i.className="download-header-right";const p=P(t.tagBadges,o.showAll,t.matchedUserTag);i.appendChild(p),l.appendChild(i)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(fe(t));const h=e.querySelector(".download-status");h&&h.textContent!==t.status&&(h.textContent=t.status,h.className=`download-status ${t.status}`);const m=e.querySelector(".progress-container");if(m&&t.progress!==void 0){const i=m.querySelector(".progress-bar"),p=m.querySelector(".progress-text"),c=m.querySelector(".missing-text");if(i){const d=i.querySelector(".downloaded");d&&(d.style.width=t.progress+"%")}if(p&&(p.textContent=t.progress+"%"),c){const d=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&d>0?c.textContent=`(missing ${y.toFixed(1)} of ${d.toFixed(1)} MB)`:c.textContent=""}}const u=e.querySelector('.detail-item[data-label="Speed"] .detail-value');u&&t.speed!==void 0&&(u.textContent=he(t.speed));const g=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(g&&t.eta!==void 0&&(g.textContent=t.eta),t.qbittorrent){const i=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');i&&t.seeds!==void 0&&(i.textContent=t.seeds);const p=e.querySelector('.detail-item[data-label="Peers"] .detail-value');p&&t.peers!==void 0&&(p.textContent=t.peers);const c=e.querySelector('.detail-item[data-label="Availability"]');c&&t.availability!==void 0&&(c.querySelector(".detail-value").textContent=`${t.availability}%`,c.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function tt(e,t){if(console.log("[Blocklist] Clicked, download:",t),console.log("[Blocklist] Required fields:",{arrQueueId:t.arrQueueId,arrType:t.arrType,arrInstanceUrl:t.arrInstanceUrl,arrInstanceKey:t.arrInstanceKey,arrContentId:t.arrContentId,arrContentIds:t.arrContentIds,arrSeriesId:t.arrSeriesId,arrContentType:t.arrContentType,isAdmin:o.isAdmin,canBlocklist:t.canBl
Line truncated
|
||||
|
||||
This will:
|
||||
• Remove the download from the download client
|
||||
• Add this release to the blocklist
|
||||
• Trigger an automatic search for a new release`)){e.disabled=!0,e.textContent="⏳ Working…";try{await Pe(t),e.textContent="✓ Done — searching…",e.className="blocklist-search-btn success"}catch(s){console.error("[Blocklist] Error:",s),e.disabled=!1,e.textContent="⛔ Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${s.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger an automatic search"},4e3)}}}function st(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const r=document.createElement("div");r.className="download-cover";const d=document.createElement("img");d.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",d.alt=e.movieName||e.seriesName||e.title,d.loading="lazy",r.appendChild(d),t.appendChild(r)}const s=document.createElement("div");s.className="download-info";const n=document.createElement("div");n.className="download-header";const a=document.createElement("span");if(a.className=`download-type ${e.type}`,e.type==="series")a.textContent="📺 Series";else if(e.type==="movie")a.textContent="🎬 Movie";else if(e.type==="torrent"){const r=e.instanceName?` (${e.instanceName})`:"";a.textContent=`📥 Torrent${r}`}else a.textContent=e.type;const i=document.createElement("span");if(i.className=`download-status ${e.status}`,i.textContent=e.status,n.appendChild(a),n.appendChild(i),e.importIssues&&e.importIssues.length>0){const r=document.createElement("span");r.className="import-issue-badge",r.textContent="Import Pending",r.setAttribute("data-tooltip",e.importIssues.join(`
|
||||
`)),n.appendChild(r)}if((o.isAdmin||e.canBlocklist)&&e.arrQueueId){const r=document.createElement("button");r.className="blocklist-search-btn",r.textContent="⛔ Blocklist & Search",r.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",r.addEventListener("click",()=>tt(r,e)),n.appendChild(r)}const l=document.createElement("div");l.className="download-header-right";const m=P(e.tagBadges,o.showAll,e.matchedUserTag);l.appendChild(m),n.appendChild(l),e.client&&t.appendChild(fe(e));const u=document.createElement("h3");if(u.className="download-title",u.textContent=e.title,s.appendChild(n),s.appendChild(u),e.seriesName){const r=document.createElement("p");r.className="download-series";const d=oe(e);d.hasChildNodes()&&(r.appendChild(d),r.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Series: ${e.seriesName}`,r.appendChild(p),s.appendChild(r);const h=pe(e.episodes);h&&s.appendChild(h)}if(e.movieName){const r=document.createElement("p");r.className="download-movie";const d=oe(e);d.hasChildNodes()&&(r.appendChild(d),r.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Movie: ${e.movieName}`,r.appendChild(p),s.appendChild(r)}const c=document.createElement("div");c.className="download-details";const g=L("Size",Ye(e.size));if(c.appendChild(g),e.progress!==void 0){const r=document.createElement("div");r.className="detail-item progress-item",r.dataset.label="Progress";const d=document.createElement("span");d.className="detail-label",d.textContent="Progress";const p=document.createElement("div");p.className="progress-container";const h=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,b=parseFloat(e.progress)||0,v=document.createElement("div");if(v.className="progress-bar",b>0){const k=document.createElement("div");k.className="progress-segment downloaded",k.style.width=b+"%",v.appendChild(k)}p.appendChild(v);const x=document.createElement("span");if(x.className="progress-text",x.textContent=e.progress+"%",p.appendChild(x),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&y>0&&h>0){const k=document.createElement("span");k.className="missing-text",k.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`,p.appendChild(k)}r.appendChild(d),r.appendChild(p),c.appendChild(r)}if(e.speed&&e.speed>0){const r=L("Speed",he(e.speed));c.appendChild(r)}if(e.eta){const r=L("ETA",e.eta);c.appendChild(r)}if(e.qbittorrent){if(e.seeds!==void 0){const r=L("Seeds",e.seeds);c.appendChild(r)}if(e.peers!==void 0){const r=L("Peers",e.peers);c.appendChild(r)}if(e.availability!==void 0){const r=L("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&r.classList.add("availability-warning"),c.appendChild(r)}}if(e.completedAt){const r=L("Completed",nt(e.completedAt));c.appendChild(r)}if(o.isAdmin&&(e.downloadPath||e.targetPath)){const r=document.createElement("div");if(r.className="download-paths",e.downloadPath){const d=document.createElement("div");d.className="path-item",d.innerHTML='<span class="path-label">Download:</span> <span class="path-value">'+ne(e.downloadPath)+"</span>",r.appendChild(d)}if(e.targetPath){const d=document.createElement("div");d.className="path-item",d.innerHTML='<span class="path-label">Target:</span> <span class="path-value">'+ne(e.targetPath)+"</span>",r.appendChild(d)}c.appendChild(r)}return s.appendChild(c),t.appendChild(s),t}function L(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const n=document.createElement("span");n.className="detail-label",n.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(n),s.appendChild(a),s}function nt(e){return e?new Date(e).toLocaleString():"N/A"}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{o.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(o.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(s){console.error("[Migration] Failed to migrate download client filter:",s)}else try{const s=localStorage.getItem("sofarr-download-clients");o.selectedDownloadClients=s?JSON.parse(s):[]}catch(s){console.error("[Migration] Failed to load download client filter:",s),o.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(o.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");o.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-request-types");t&&(o.selectedRequestTypes=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request types:",t),o.selectedRequestTypes=["movie","tv"]}try{const t=localStorage
Line truncated
|
||||
• Trigger an automatic search for a new release`)){e.disabled=!0,e.textContent="⏳ Working…";try{await Pe(t),e.textContent="✓ Done — searching…",e.className="blocklist-search-btn success"}catch(s){console.error("[Blocklist] Error:",s),e.disabled=!1,e.textContent="⛔ Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${s.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger an automatic search"},4e3)}}}function st(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const i=document.createElement("div");i.className="download-cover";const p=document.createElement("img");p.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",p.alt=e.movieName||e.seriesName||e.title,p.loading="lazy",i.appendChild(p),t.appendChild(i)}const s=document.createElement("div");s.className="download-info";const n=document.createElement("div");n.className="download-header";const a=document.createElement("span");if(a.className=`download-type ${e.type}`,e.type==="series")a.textContent="📺 Series";else if(e.type==="movie")a.textContent="🎬 Movie";else if(e.type==="torrent"){const i=e.instanceName?` (${e.instanceName})`:"";a.textContent=`📥 Torrent${i}`}else a.textContent=e.type;const r=document.createElement("span");if(r.className=`download-status ${e.status}`,r.textContent=e.status,n.appendChild(a),n.appendChild(r),e.importIssues&&e.importIssues.length>0){const i=document.createElement("span");i.className="import-issue-badge",i.textContent="Import Pending",i.setAttribute("data-tooltip",e.importIssues.join(`
|
||||
`)),n.appendChild(i)}if((o.isAdmin||e.canBlocklist)&&e.arrQueueId){const i=document.createElement("button");i.className="blocklist-search-btn",i.textContent="⛔ Blocklist & Search",i.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",i.addEventListener("click",()=>tt(i,e)),n.appendChild(i)}const l=document.createElement("div");l.className="download-header-right";const h=P(e.tagBadges,o.showAll,e.matchedUserTag);l.appendChild(h),n.appendChild(l),e.client&&t.appendChild(fe(e));const m=document.createElement("h3");if(m.className="download-title",m.textContent=e.title,s.appendChild(n),s.appendChild(m),e.seriesName){const i=document.createElement("p");i.className="download-series";const p=oe(e);p.hasChildNodes()&&(i.appendChild(p),i.appendChild(document.createTextNode(" ")));const c=document.createElement("span");c.textContent=`Series: ${e.seriesName}`,i.appendChild(c),s.appendChild(i);const d=pe(e.episodes);d&&s.appendChild(d)}if(e.movieName){const i=document.createElement("p");i.className="download-movie";const p=oe(e);p.hasChildNodes()&&(i.appendChild(p),i.appendChild(document.createTextNode(" ")));const c=document.createElement("span");c.textContent=`Movie: ${e.movieName}`,i.appendChild(c),s.appendChild(i)}const u=document.createElement("div");u.className="download-details";const g=T("Size",Ye(e.size));if(u.appendChild(g),e.progress!==void 0){const i=document.createElement("div");i.className="detail-item progress-item",i.dataset.label="Progress";const p=document.createElement("span");p.className="detail-label",p.textContent="Progress";const c=document.createElement("div");c.className="progress-container";const d=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,b=parseFloat(e.progress)||0,v=document.createElement("div");if(v.className="progress-bar",b>0){const E=document.createElement("div");E.className="progress-segment downloaded",E.style.width=b+"%",v.appendChild(E)}c.appendChild(v);const w=document.createElement("span");if(w.className="progress-text",w.textContent=e.progress+"%",c.appendChild(w),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&y>0&&d>0){const E=document.createElement("span");E.className="missing-text",E.textContent=`(missing ${y.toFixed(1)} of ${d.toFixed(1)} MB)`,c.appendChild(E)}i.appendChild(p),i.appendChild(c),u.appendChild(i)}if(e.speed&&e.speed>0){const i=T("Speed",he(e.speed));u.appendChild(i)}if(e.eta){const i=T("ETA",e.eta);u.appendChild(i)}if(e.qbittorrent){if(e.seeds!==void 0){const i=T("Seeds",e.seeds);u.appendChild(i)}if(e.peers!==void 0){const i=T("Peers",e.peers);u.appendChild(i)}if(e.availability!==void 0){const i=T("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&i.classList.add("availability-warning"),u.appendChild(i)}}if(e.completedAt){const i=T("Completed",nt(e.completedAt));u.appendChild(i)}if(o.isAdmin&&(e.downloadPath||e.targetPath)){const i=document.createElement("div");if(i.className="download-paths",e.downloadPath){const p=document.createElement("div");p.className="path-item",p.innerHTML='<span class="path-label">Download:</span> <span class="path-value">'+ne(e.downloadPath)+"</span>",i.appendChild(p)}if(e.targetPath){const p=document.createElement("div");p.className="path-item",p.innerHTML='<span class="path-label">Target:</span> <span class="path-value">'+ne(e.targetPath)+"</span>",i.appendChild(p)}u.appendChild(i)}return s.appendChild(u),t.appendChild(s),t}function T(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const n=document.createElement("span");n.className="detail-label",n.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(n),s.appendChild(a),s}function nt(e){return e?new Date(e).toLocaleString():"N/A"}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{o.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(o.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(s){console.error("[Migration] Failed to migrate download client filter:",s)}else try{const s=localStorage.getItem("sofarr-download-clients");o.selectedDownloadClients=s?JSON.parse(s):[]}catch(s){console.error("[Migration] Failed to load download client filter:",s),o.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(o.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");o.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-request-types");t&&(o.selectedRequestTypes=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request types:",t),o.selectedRequestTypes=["movie","tv"]}try{const t=localStorage
Line truncated
|
||||
<div class="status-header">
|
||||
<h3>Server Status</h3>
|
||||
<button class="status-close" id="status-close-btn">×</button>
|
||||
@@ -19,26 +19,26 @@ This will:
|
||||
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Data Refresh</div>`;const c=e.polling.intervalMs,r=(e.clients||[]).filter(f=>f.type==="sse");e.polling.enabled?u+=`<div class="status-row"><span>Background poll</span><span>${c/1e3}s</span></div>`:u+='<div class="status-row"><span>Background poll</span><span>Disabled</span></div>';const d=r.length>0?'<span class="status-fg-badge">SSE push</span>':e.polling.enabled?"Background":"On-demand (idle)";u+=`<div class="status-row"><span>Delivery mode</span><span>${d}</span></div>`,u+=`<div class="status-row"><span>SSE clients</span><span>${r.length}</span></div>`;for(const f of r){const I=Math.round((Date.now()-f.connectedAt)/1e3);u+=`<div class="status-row status-row-sub"><span>${R(f.user)}</span><span>connected ${I}s ago</span></div>`}if(u+="</div>",o.isAdmin&&e.webhooks){const f=e.webhooks,I=(v=f.sonarr)!=null&&v.enabled?"●":"○",S=(x=f.radarr)!=null&&x.enabled?"●":"○",B=(k=f.ombi)!=null&&k.enabled?"●":"○",qe=((J=f.sonarr)==null?void 0:J.eventsReceived)||0,xe=((Q=f.radarr)==null?void 0:Q.eventsReceived)||0,Ne=((K=f.ombi)==null?void 0:K.eventsReceived)||0,Re=((X=f.sonarr)==null?void 0:X.pollsSkipped)||0,De=((V=f.radarr)==null?void 0:V.pollsSkipped)||0,Ae=((Y=f.ombi)==null?void 0:Y.pollsSkipped)||0;u+=`
|
||||
<div class="status-card-title">Data Refresh</div>`;const u=e.polling.intervalMs,i=(e.clients||[]).filter(f=>f.type==="sse");e.polling.enabled?m+=`<div class="status-row"><span>Background poll</span><span>${u/1e3}s</span></div>`:m+='<div class="status-row"><span>Background poll</span><span>Disabled</span></div>';const p=i.length>0?'<span class="status-fg-badge">SSE push</span>':e.polling.enabled?"Background":"On-demand (idle)";m+=`<div class="status-row"><span>Delivery mode</span><span>${p}</span></div>`,m+=`<div class="status-row"><span>SSE clients</span><span>${i.length}</span></div>`;for(const f of i){const B=Math.round((Date.now()-f.connectedAt)/1e3);m+=`<div class="status-row status-row-sub"><span>${R(f.user)}</span><span>connected ${B}s ago</span></div>`}if(m+="</div>",o.isAdmin&&e.webhooks){const f=e.webhooks,B=(v=f.sonarr)!=null&&v.enabled?"●":"○",S=(w=f.radarr)!=null&&w.enabled?"●":"○",L=(E=f.ombi)!=null&&E.enabled?"●":"○",qe=((J=f.sonarr)==null?void 0:J.eventsReceived)||0,xe=((Q=f.radarr)==null?void 0:Q.eventsReceived)||0,Ne=((K=f.ombi)==null?void 0:K.eventsReceived)||0,Re=((X=f.sonarr)==null?void 0:X.pollsSkipped)||0,De=((V=f.radarr)==null?void 0:V.pollsSkipped)||0,Ae=((Y=f.ombi)==null?void 0:Y.pollsSkipped)||0;m+=`
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${I} ${(Z=f.sonarr)!=null&&Z.enabled?"Enabled":"Disabled"}</span></div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${B} ${(Z=f.sonarr)!=null&&Z.enabled?"Enabled":"Disabled"}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${S} ${(ee=f.radarr)!=null&&ee.enabled?"Enabled":"Disabled"}</span></div>
|
||||
<div class="status-row"><span>Ombi</span><span>${B} ${(te=f.ombi)!=null&&te.enabled?"Enabled":"Disabled"}</span></div>
|
||||
<div class="status-row"><span>Ombi</span><span>${L} ${(te=f.ombi)!=null&&te.enabled?"Enabled":"Disabled"}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${qe} R:${xe} O:${Ne}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${Re} R:${De} O:${Ae}</span></div>
|
||||
</div>`}const p=e.polling.lastPoll;if(p){const f=Math.round((Date.now()-new Date(p.timestamp).getTime())/1e3);u+=`
|
||||
</div>`}const c=e.polling.lastPoll;if(c){const f=Math.round((Date.now()-new Date(c.timestamp).getTime())/1e3);m+=`
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${p.totalMs}ms total, ${f}s ago)</div>
|
||||
<div class="status-timings">`;const I=p.tasks.reduce((S,B)=>Math.max(S,B.ms),1);for(const S of p.tasks){const B=Math.max(2,S.ms/I*100);u+=`
|
||||
<div class="status-card-title">Last Poll (${c.totalMs}ms total, ${f}s ago)</div>
|
||||
<div class="status-timings">`;const B=c.tasks.reduce((S,L)=>Math.max(S,L.ms),1);for(const S of c.tasks){const L=Math.max(2,S.ms/B*100);m+=`
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${R(S.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${B.toFixed(1)}"></div></div>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${L.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${S.ms}ms</span>
|
||||
</div>`}u+="</div></div>"}u+=`
|
||||
</div>`}m+="</div></div>"}m+=`
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Cache (${e.cache.entryCount} entries, ${m} KB)</div>
|
||||
<div class="status-card-title">Cache (${e.cache.entryCount} entries, ${h} KB)</div>
|
||||
<table class="status-table">
|
||||
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
||||
<tbody>`;for(const f of e.cache.entries){const I=f.sizeBytes>1024?(f.sizeBytes/1024).toFixed(1)+" KB":f.sizeBytes+" B",S=f.expired?'<span class="status-expired">expired</span>':(f.ttlRemainingMs/1e3).toFixed(0)+"s",B=f.itemCount!==null?f.itemCount:"—";u+=`<tr><td><code>${R(f.key)}</code></td><td>${B}</td><td>${I}</td><td>${S}</td></tr>`}u+="</tbody></table></div></div>";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(se=y==null?void 0:y.children)==null?void 0:se.length,"HTML length:",u.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=u,console.log("[Status] HTML rendered, contentDiv innerHTML length:",h.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",It),t.querySelectorAll(".timing-bar[data-w]").forEach(f=>{f.style.width=f.dataset.w+"%"})}function R(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Lt(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function Tt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function D(e){return new Promise(t=>{const s=Date.now()-(e||0),n=Math.max(0,Fe-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const l=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(l),a.classList.add("hidden"),t()},{once:!0})},n)})}async function qt(){const e=Date.now();try{(await Ue()).authenticated?(Ce(),Ie(),_(),await D(e)):(await D(e),W())}catch(t){console.error("Authentication check failed:",t),await D(e),W()}}async function xt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,n=document.getElementById("remember-me").checked;try{const a=await We(t,s,n);if(a.success){await Lt(),Tt(),await new Promise(l=>requestAnimationFrame(()=>requestAnimationFrame(l))),Ce(),Ie();const i=Date.now();_(),await D(i)}else le(a.error||"Login failed")}catch(a){le("Login failed. Please try again."),console.error(a)}}async function Nt(){try{Se(),Ee(),o.statusRefreshHandle&&(clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=null),await He(),o.currentUser=null,mt(),W()}catch(e){console.error("Logout failed:",e)}}function W(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),Rt()}function Ce(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=o.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");o.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const n=document.getElementById("history-days");n&&(n.value=o.historyDays),ut()}function le(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function Rt(){document.getElementById("login-error").classList.add("hidden")}function Dt(){document.getElementById("error-message").classList.add("hidden")}function Ie(){document.getElementById("loading").classList.remove("hidden")}function At(){document.getElementById("loading").classList.add("hidden")}function Ft(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),n=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ce(!0)),n&&n.addEventListener("click",()=>ce(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",H),H())}function H(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",o.downloadClients.forEach((t,s)=>{const n=document.createElement("div");n.className="download-client-option",n.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.className="download-client-checkbox",a.id=`client-${s}`,a.checked=o.selectedDownloadClients.includes(s),a.addEventListener("change",()=>$t(s));const i=document.createElement("span");i.className="download-client-icon";const l=document.createElement("img");l.src=`/images/clients/${t.type}.svg`,l.aLine truncated
|
||||
${t.stack||""}`;if(typeof t=="object")try{return JSON.stringify(t)}catch{return String(t)}return String(t)}).join(" ")}function U(e,t){const s=is(t);e==="info"?os.apply(console,t):e==="warn"?as.apply(console,t):e==="error"&&F.apply(console,t),!A&&(w.push({timestamp:new Date().toISOString(),level:e,message:s}),w.length>=ss&&Te())}async function Te(){if(w.length===0||A)return;A=!0;const e=[...w];w.length=0;try{const t=await fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0});t.ok||F.call(console,"[clientLogCapture] Ingestion server returned error status:",t.status)}catch(t){F.call(console,"[clientLogCapture] Ingestion post request failed:",t.message)}finally{A=!1}}function ls(){if(w.length===0)return;const e=[...w];w.length=0;try{const t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon("/api/debug/client-logs",t)}catch{try{fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0})}catch{}}}async function cs(){if(!me)try{const e=await fetch("/api/debug/status");if(!e.ok)return;const t=await e.json();t&&t.enabled===!0&&(console.log=(...s)=>U("info",s),console.warn=(...s)=>U("warn",s),console.error=(...s)=>U("error",s),rs=setInterval(Te,ns),window.addEventListener("beforeunload",ls),me=!0,console.log("[clientLogCapture] Browser console logging interceptor initialized successfully."))}catch(e){F.call(console,"[clientLogCapture] Check failed to start interceptor:",e.message)}}document.addEventListener("DOMContentLoaded",()=>{cs();const e=document.getElementById("login-form");e&&e.addEventListener("submit",xt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",Nt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",i=>pt(i.target.checked));const n=document.getElementById("status-btn");n&&n.addEventListener("click",Ct);const a=document.getElementById("home-btn");a&&a.addEventListener("click",ts),Yt(),es(),Ft(),Vt(),dt(),ft(),je().then(i=>{const l=document.getElementById("app-version");l&&i&&(l.textContent="v"+i)}),qt()});
|
||||
<tbody>`;for(const f of e.cache.entries){const B=f.sizeBytes>1024?(f.sizeBytes/1024).toFixed(1)+" KB":f.sizeBytes+" B",S=f.expired?'<span class="status-expired">expired</span>':(f.ttlRemainingMs/1e3).toFixed(0)+"s",L=f.itemCount!==null?f.itemCount:"—";m+=`<tr><td><code>${R(f.key)}</code></td><td>${L}</td><td>${B}</td><td>${S}</td></tr>`}m+="</tbody></table></div></div>";const d=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!d,"panel children:",(se=y==null?void 0:y.children)==null?void 0:se.length,"HTML length:",m.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),d?(d.innerHTML=m,console.log("[Status] HTML rendered, contentDiv innerHTML length:",d.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",It),t.querySelectorAll(".timing-bar[data-w]").forEach(f=>{f.style.width=f.dataset.w+"%"})}function R(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Lt(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function Tt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function D(e){return new Promise(t=>{const s=Date.now()-(e||0),n=Math.max(0,Fe-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const l=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(l),a.classList.add("hidden"),t()},{once:!0})},n)})}async function qt(){const e=Date.now();try{(await Ue()).authenticated?(Ce(),Ie(),_(),await D(e)):(await D(e),W())}catch(t){console.error("Authentication check failed:",t),await D(e),W()}}async function xt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,n=document.getElementById("remember-me").checked;try{const a=await We(t,s,n);if(a.success){await Lt(),Tt(),await new Promise(l=>requestAnimationFrame(()=>requestAnimationFrame(l))),Ce(),Ie();const r=Date.now();_(),await D(r)}else le(a.error||"Login failed")}catch(a){le("Login failed. Please try again."),console.error(a)}}async function Nt(){try{Se(),Ee(),o.statusRefreshHandle&&(clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=null),await He(),o.currentUser=null,mt(),W()}catch(e){console.error("Logout failed:",e)}}function W(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),Rt()}function Ce(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=o.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");o.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const n=document.getElementById("history-days");n&&(n.value=o.historyDays),ut()}function le(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function Rt(){document.getElementById("login-error").classList.add("hidden")}function Dt(){document.getElementById("error-message").classList.add("hidden")}function Ie(){document.getElementById("loading").classList.remove("hidden")}function At(){document.getElementById("loading").classList.add("hidden")}function Ft(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),n=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ce(!0)),n&&n.addEventListener("click",()=>ce(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",H),H())}function H(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",o.downloadClients.forEach((t,s)=>{const n=document.createElement("div");n.className="download-client-option",n.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.className="download-client-checkbox",a.id=`client-${s}`,a.checked=o.selectedDownloadClients.includes(s),a.addEventListener("change",()=>$t(s));const r=document.createElement("span");r.className="download-client-icon";const l=document.createElement("img");l.src=`/images/clients/${t.type}.svg`,l.aLine truncated
|
||||
${t.stack||""}`;if(typeof t=="object")try{return JSON.stringify(t)}catch{return String(t)}return String(t)}).join(" ")}function U(e,t){const s=is(t);e==="info"?os.apply(console,t):e==="warn"?as.apply(console,t):e==="error"&&F.apply(console,t),!A&&(C.push({timestamp:new Date().toISOString(),level:e,message:s}),C.length>=ss&&Te())}async function Te(){if(C.length===0||A)return;A=!0;const e=[...C];C.length=0;try{const t=await fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0});t.ok||F.call(console,"[clientLogCapture] Ingestion server returned error status:",t.status)}catch(t){F.call(console,"[clientLogCapture] Ingestion post request failed:",t.message)}finally{A=!1}}function ls(){if(C.length===0)return;const e=[...C];C.length=0;try{const t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon("/api/debug/client-logs",t)}catch{try{fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0})}catch{}}}async function cs(){if(!me)try{const e=await fetch("/api/debug/status");if(!e.ok)return;const t=await e.json();t&&t.enabled===!0&&(console.log=(...s)=>U("info",s),console.warn=(...s)=>U("warn",s),console.error=(...s)=>U("error",s),rs=setInterval(Te,ns),window.addEventListener("beforeunload",ls),me=!0,console.log("[clientLogCapture] Browser console logging interceptor initialized successfully."))}catch(e){F.call(console,"[clientLogCapture] Check failed to start interceptor:",e.message)}}document.addEventListener("DOMContentLoaded",()=>{cs();const e=document.getElementById("login-form");e&&e.addEventListener("submit",xt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",Nt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>pt(r.target.checked));const n=document.getElementById("status-btn");n&&n.addEventListener("click",Ct);const a=document.getElementById("home-btn");a&&a.addEventListener("click",ts),Yt(),es(),Ft(),Vt(),dt(),ft(),je().then(r=>{const l=document.getElementById("app-version");l&&r&&(l.textContent="v"+r)}),qt()});
|
||||
+1
-1
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.27"
|
||||
* example: "1.7.31"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
|
||||
@@ -104,6 +104,11 @@ class QBittorrentClient extends DownloadClient {
|
||||
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
||||
const data = response.data;
|
||||
|
||||
if (!data) {
|
||||
logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`);
|
||||
return Array.from(this.torrentMap.values());
|
||||
}
|
||||
|
||||
if (data.full_update) {
|
||||
// Full refresh: rebuild the entire map
|
||||
this.torrentMap.clear();
|
||||
@@ -249,6 +254,14 @@ class QBittorrentClient extends DownloadClient {
|
||||
downloaded: downloadedSize,
|
||||
speed: torrent.dlspeed,
|
||||
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
||||
// Connected peer counts (Issue #64). qBittorrent exposes:
|
||||
// num_seeds — connected seeds (peers we have a connection to)
|
||||
// num_leechs — connected leechers (peers downloading from us)
|
||||
// num_complete / num_incomplete — *swarm* totals reported by tracker
|
||||
// We expose the connected counts to stay consistent with what other
|
||||
// clients (e.g. Transmission via peersConnected/peersSendingToUs) report.
|
||||
seeds: torrent.num_seeds ?? 0,
|
||||
peers: torrent.num_leechs ?? 0,
|
||||
category: torrent.category || undefined,
|
||||
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
||||
savePath: torrent.content_path || torrent.save_path || undefined,
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
||||
|
||||
## SSE Streaming
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.7.27
|
||||
version: 1.7.31
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
|
||||
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
|
||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||
|
||||
|
||||
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
ombiBaseUrl
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: user.name,
|
||||
isAdmin,
|
||||
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
ombiBaseUrl
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||
}
|
||||
|
||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
||||
id: c.getInstanceId(),
|
||||
|
||||
+30
-30
@@ -5,6 +5,7 @@ const { logToFile } = require('../utils/logger');
|
||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
||||
const cache = require('../utils/cache');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
const { buildArrQueueCache } = require('../utils/arrQueueHelpers');
|
||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
@@ -106,9 +107,14 @@ function pruneReplayCache() {
|
||||
// Prune the replay cache once per minute
|
||||
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||
|
||||
function isReplay(eventType, instanceName, eventDate) {
|
||||
function isReplay(eventType, instanceName, eventDate, contentId) {
|
||||
if (!eventDate) return false;
|
||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
||||
// Content-aware replay key: incorporates downloadId / series.id / movie.id when
|
||||
// available so that distinct events sharing the same `date` (e.g. multiple
|
||||
// Grab events for episodes in a season pack fired in the same second) do not
|
||||
// falsely collide. Falls back to the prior shape when contentId is absent
|
||||
// (e.g. Test events) so existing behaviour is preserved.
|
||||
const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`;
|
||||
if (recentEvents.has(key)) return true;
|
||||
recentEvents.set(key, Date.now());
|
||||
return false;
|
||||
@@ -202,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const sonarrQueues = queuesByType.sonarr || [];
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||
}
|
||||
@@ -232,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const radarrQueues = queuesByType.radarr || [];
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||
}
|
||||
@@ -480,11 +466,18 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
|
||||
const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||
const inst = matchedInst || sonarrInstances[0];
|
||||
if (!matchedInst && instanceName) {
|
||||
logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||
}
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
// Content-aware replay key components (Issue #62)
|
||||
const contentId = req.body.downloadId || req.body.series?.id || null;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
@@ -634,11 +627,18 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
|
||||
const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||
const inst = matchedInst || radarrInstances[0];
|
||||
if (!matchedInst && instanceName) {
|
||||
logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||
}
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
// Content-aware replay key components (Issue #62)
|
||||
const contentId = req.body.downloadId || req.body.movie?.id || null;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
//
|
||||
// Shared helpers for assembling the cached *arr queue payload.
|
||||
//
|
||||
// Both the background poller (`server/utils/poller.js`) and the webhook
|
||||
// processor (`server/routes/webhook.js`) build the `poll:sonarr-queue` and
|
||||
// `poll:radarr-queue` cache entries from an array of per-instance queue
|
||||
// responses. Historically the same `flatMap` block was duplicated across all
|
||||
// four call sites (Sonarr + Radarr × poller + webhook) and had begun to drift.
|
||||
//
|
||||
// This module centralises that logic, adds defensive null-guards, and — for
|
||||
// Sonarr only — annotates season-pack records (queue entries sharing a
|
||||
// `downloadId`) with `isSeasonPack` and `episodeCount`. See Issue #61.
|
||||
//
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
/**
|
||||
* Build the flattened, instance-tagged `records` array for the
|
||||
* `poll:sonarr-queue` / `poll:radarr-queue` cache entry.
|
||||
*
|
||||
* @param {Array<{ instance: string, data: { records?: Array<object> } }>} queues
|
||||
* Per-instance queue responses as returned by
|
||||
* `arrRetrieverRegistry.getQueuesByType()` (or the equivalent batched
|
||||
* retrieval in the poller).
|
||||
* @param {Array<{ id: string, url: string, apiKey: string, name?: string }>} instances
|
||||
* Configured instances; used to resolve `_instanceUrl` / `_instanceKey`.
|
||||
* @param {'series'|'movie'} mediaKey
|
||||
* Sonarr records embed a `series` object; Radarr records embed a `movie`
|
||||
* object. The embedded object is annotated with `_instanceUrl` so that
|
||||
* downstream link builders work.
|
||||
* @returns {Array<object>} The flattened, annotated records array.
|
||||
*/
|
||||
function buildArrQueueCache(queues, instances, mediaKey) {
|
||||
if (!Array.isArray(queues) || queues.length === 0) return [];
|
||||
if (mediaKey !== 'series' && mediaKey !== 'movie') {
|
||||
logToFile(`[arrQueueHelpers] Invalid mediaKey "${mediaKey}"; expected 'series' or 'movie'`);
|
||||
return [];
|
||||
}
|
||||
const safeInstances = Array.isArray(instances) ? instances : [];
|
||||
|
||||
const out = [];
|
||||
for (const q of queues) {
|
||||
try {
|
||||
if (!q || !q.data) continue;
|
||||
const inst = safeInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
const records = Array.isArray(q.data.records) ? q.data.records : [];
|
||||
for (const r of records) {
|
||||
try {
|
||||
if (!r) continue;
|
||||
if (r[mediaKey]) {
|
||||
r[mediaKey]._instanceUrl = url;
|
||||
}
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
out.push(r);
|
||||
} catch (perRecordErr) {
|
||||
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} record: ${perRecordErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (perInstanceErr) {
|
||||
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} queue payload: ${perInstanceErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sonarr-only: season pack annotation. Group by downloadId; entries that
|
||||
// share a downloadId are episodes belonging to the same release (a season
|
||||
// pack). Movies (mediaKey === 'movie') are single-record by nature.
|
||||
if (mediaKey === 'series') {
|
||||
try {
|
||||
const groups = new Map();
|
||||
for (const r of out) {
|
||||
const dlId = r && r.downloadId;
|
||||
if (!dlId) continue;
|
||||
if (!groups.has(dlId)) groups.set(dlId, []);
|
||||
groups.get(dlId).push(r);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
if (group.length > 1) {
|
||||
for (const r of group) {
|
||||
r.isSeasonPack = true;
|
||||
r.episodeCount = group.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (annotateErr) {
|
||||
logToFile(`[arrQueueHelpers] Season-pack annotation failed: ${annotateErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildArrQueueCache
|
||||
};
|
||||
@@ -115,10 +115,10 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||
requests.forEach(req => {
|
||||
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||
// Fallback to checking for TV specific IDs.
|
||||
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.theTvDbId;
|
||||
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||
|
||||
if (isTv) {
|
||||
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
|
||||
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||
if (!tvdbId) return;
|
||||
|
||||
for (const instData of sonarrData) {
|
||||
@@ -145,8 +145,95 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||
});
|
||||
}
|
||||
|
||||
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
|
||||
if (!isAdmin || !Array.isArray(downloads)) return;
|
||||
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||
|
||||
const [sonarrData, radarrData] = await Promise.all([
|
||||
Promise.all(sonarrRetrievers.map(async r => {
|
||||
try {
|
||||
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': r.apiKey }
|
||||
});
|
||||
return { instance: r, series: response.data || [] };
|
||||
} catch {
|
||||
return { instance: r, series: [] };
|
||||
}
|
||||
})),
|
||||
Promise.all(radarrRetrievers.map(async r => {
|
||||
try {
|
||||
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': r.apiKey }
|
||||
});
|
||||
return { instance: r, movies: response.data || [] };
|
||||
} catch {
|
||||
return { instance: r, movies: [] };
|
||||
}
|
||||
}))
|
||||
]);
|
||||
|
||||
downloads.forEach(dl => {
|
||||
// Determine if it's TV (series) or Movie
|
||||
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
|
||||
|
||||
if (isTv) {
|
||||
// Look for a match in Sonarr instances
|
||||
for (const instData of sonarrData) {
|
||||
const match = instData.series.find(s => {
|
||||
if (!s) return false;
|
||||
// Match by database series ID if the instance matches
|
||||
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
|
||||
return true;
|
||||
}
|
||||
// Fallback to seriesName matching
|
||||
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (match && match.titleSlug) {
|
||||
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||
dl.arrType = 'sonarr';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
|
||||
// Look for a match in Radarr instances
|
||||
for (const instData of radarrData) {
|
||||
const match = instData.movies.find(m => {
|
||||
if (!m) return false;
|
||||
// Match by database movie ID if instance matches
|
||||
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
|
||||
return true;
|
||||
}
|
||||
// Fallback to movieName matching
|
||||
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (match && match.titleSlug) {
|
||||
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||
dl.arrType = 'radarr';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractRequestedUser,
|
||||
filterRequestsByUser,
|
||||
decorateRequestsWithArrLinks
|
||||
decorateRequestsWithArrLinks,
|
||||
decorateDownloadsWithArrLinks
|
||||
};
|
||||
+3
-22
@@ -3,6 +3,7 @@ const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
const { buildArrQueueCache } = require('./arrQueueHelpers');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
@@ -237,17 +238,7 @@ async function pollAllServices() {
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
@@ -265,17 +256,7 @@ async function pollAllServices() {
|
||||
// Radarr
|
||||
if (shouldPollRadarr) {
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
|
||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
||||
expect(result.childNodes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||
import { state } from '../../../client/src/state.js';
|
||||
|
||||
describe('createDownloadCard rendering details', () => {
|
||||
let originalState;
|
||||
|
||||
beforeEach(() => {
|
||||
originalState = { ...state };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset global state
|
||||
Object.assign(state, originalState);
|
||||
});
|
||||
|
||||
describe('createClientLogo and fallbacks', () => {
|
||||
it('renders client logo img tag when client is configured', () => {
|
||||
const dl = {
|
||||
title: 'Test Download',
|
||||
type: 'series',
|
||||
client: 'qbittorrent',
|
||||
instanceName: 'Qbit Main'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||
expect(wrapper).toBeTruthy();
|
||||
|
||||
const img = wrapper.querySelector('img.download-client-logo');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||
expect(img.alt).toBe('Qbit Main icon');
|
||||
});
|
||||
|
||||
it('falls back to character avatar text on img load error', () => {
|
||||
const dl = {
|
||||
title: 'Test Download',
|
||||
type: 'series',
|
||||
client: 'transmission'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||
const img = wrapper.querySelector('img');
|
||||
|
||||
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||
img.onerror();
|
||||
|
||||
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||
expect(wrapper.textContent).toBe('T');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServiceIcons deep-linking', () => {
|
||||
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||
|
||||
const dl = {
|
||||
title: 'Mandalorian S01E01',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian',
|
||||
ombiLink: 'https://ombi.test/request/42',
|
||||
ombiTooltip: 'View on Ombi'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const ombiLinkEl = card.querySelector('.download-series a');
|
||||
expect(ombiLinkEl).toBeTruthy();
|
||||
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||
|
||||
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.title).toBe('View on Ombi');
|
||||
});
|
||||
|
||||
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||
state.isAdmin = true; // Admin required for Sonarr link
|
||||
|
||||
const dl = {
|
||||
title: 'Mandalorian S01E01',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian',
|
||||
arrType: 'sonarr',
|
||||
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const arrLinkEl = card.querySelector('.download-series a');
|
||||
expect(arrLinkEl).toBeTruthy();
|
||||
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||
|
||||
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.title).toBe('Sonarr');
|
||||
});
|
||||
|
||||
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||
state.isAdmin = true; // Admin required for Radarr link
|
||||
|
||||
const dl = {
|
||||
title: 'Blade Runner 2049',
|
||||
type: 'movie',
|
||||
movieName: 'Blade Runner 2049',
|
||||
arrType: 'radarr',
|
||||
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const arrLinkEl = card.querySelector('.download-movie a');
|
||||
expect(arrLinkEl).toBeTruthy();
|
||||
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||
|
||||
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.title).toBe('Radarr');
|
||||
});
|
||||
|
||||
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||
state.isAdmin = false; // Non-admin
|
||||
|
||||
const dl = {
|
||||
title: 'Mandalorian S01E01',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian',
|
||||
arrType: 'sonarr',
|
||||
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||
};
|
||||
|
||||
const card = createDownloadCard(dl);
|
||||
const arrLinkEl = card.querySelector('.download-series a');
|
||||
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
|
||||
expect(dl.canBlocklist).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
|
||||
it('decorates active series downloads with Sonarr links for administrator', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
|
||||
// Seed cache: queue record exists and matches SABnzbd slot
|
||||
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||
|
||||
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.reply(200, [
|
||||
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
|
||||
]);
|
||||
|
||||
// Mock Radarr /api/v3/movie response
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.reply(200, []);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const downloads = res.body.downloads;
|
||||
const dl = downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
|
||||
expect(dl.arrType).toBe('sonarr');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1093,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||
const { cookies } = await loginAs(appInstance);
|
||||
const res = await request(appInstance)
|
||||
.get('/api/dashboard/stream')
|
||||
.query({ testClose: 'true' })
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const text = res.text;
|
||||
expect(text).toContain('data:');
|
||||
|
||||
const dataStr = text.substring(text.indexOf('{'));
|
||||
const data = JSON.parse(dataStr.trim());
|
||||
|
||||
// Payload Contract Validation
|
||||
expect(data).toHaveProperty('user');
|
||||
expect(data).toHaveProperty('isAdmin');
|
||||
expect(data).toHaveProperty('downloads');
|
||||
expect(data).toHaveProperty('downloadClients');
|
||||
expect(data).toHaveProperty('ombiRequests');
|
||||
expect(data).toHaveProperty('ombiBaseUrl');
|
||||
|
||||
expect(Array.isArray(data.downloads)).toBe(true);
|
||||
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||
});
|
||||
|
||||
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// 1. Get the route handler from the dashboard router stack
|
||||
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||
// Get the final handler (after requireAuth middleware)
|
||||
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||
|
||||
// 2. Setup mock req and res
|
||||
const mockUser = { name: 'Alice', isAdmin: false };
|
||||
const reqOnCallbacks = {};
|
||||
const mockReq = {
|
||||
user: mockUser,
|
||||
query: { showAll: 'false', testClose: 'false' },
|
||||
on: vi.fn((event, cb) => {
|
||||
reqOnCallbacks[event] = cb;
|
||||
})
|
||||
};
|
||||
|
||||
const resWrites = [];
|
||||
const mockRes = {
|
||||
setHeader: vi.fn(),
|
||||
flushHeaders: vi.fn(),
|
||||
write: vi.fn((data) => {
|
||||
resWrites.push(data);
|
||||
}),
|
||||
end: vi.fn()
|
||||
};
|
||||
|
||||
// 3. Call the handler
|
||||
await streamHandler(mockReq, mockRes);
|
||||
|
||||
// Initial payload should be written
|
||||
expect(resWrites.length).toBeGreaterThan(0);
|
||||
expect(resWrites[0]).toContain('data:');
|
||||
|
||||
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||
vi.advanceTimersByTime(25000);
|
||||
|
||||
// Check that heartbeat was written
|
||||
expect(resWrites).toContain(': heartbeat\n\n');
|
||||
|
||||
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||
expect(reqOnCallbacks['close']).toBeDefined();
|
||||
reqOnCallbacks['close']();
|
||||
|
||||
// Check that advancing time again does NOT write another heartbeat
|
||||
const beforeLength = resWrites.length;
|
||||
vi.advanceTimersByTime(25000);
|
||||
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||
});
|
||||
|
||||
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||
// 1. Setup mock instance config
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||
]);
|
||||
|
||||
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
|
||||
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||
const tvRequestsWithTvDbId = [
|
||||
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||
];
|
||||
|
||||
nock.cleanAll();
|
||||
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||
|
||||
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/series')
|
||||
.reply(200, [
|
||||
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||
]);
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
// 4. Assert decoration succeeded
|
||||
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||
expect(supermanShow).toBeDefined();
|
||||
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||
expect(supermanShow.arrType).toBe('sonarr');
|
||||
|
||||
// Clean up
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
});
|
||||
|
||||
it('handles case-insensitive username matching', async () => {
|
||||
const requestsWithMixedCase = [
|
||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||
|
||||
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||
const RADARR_BASE = 'https://radarr-decor.test';
|
||||
|
||||
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
nock.cleanAll();
|
||||
|
||||
// Reset the singleton retrievers registry so we can inject our test instances
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
|
||||
// Configure test environment variables for retrievers
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
});
|
||||
|
||||
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||
// Mock Sonarr series query
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.reply(200, [
|
||||
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||
]);
|
||||
|
||||
// Mock Radarr movie query (empty)
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.reply(200, []);
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian',
|
||||
arrSeriesId: null
|
||||
}
|
||||
];
|
||||
|
||||
await decorateDownloadsWithArrLinks(downloads, true);
|
||||
|
||||
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||
expect(downloads[0].arrType).toBe('sonarr');
|
||||
});
|
||||
|
||||
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||
// Mock Sonarr series query (empty)
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.reply(200, []);
|
||||
|
||||
// Mock Radarr movie query with matching ID
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.reply(200, [
|
||||
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||
]);
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'Blade.Runner.2049.2017.1080p',
|
||||
type: 'movie',
|
||||
movieName: 'Blade Runner 2049',
|
||||
arrInstanceUrl: RADARR_BASE,
|
||||
arrContentId: 99
|
||||
}
|
||||
];
|
||||
|
||||
await decorateDownloadsWithArrLinks(downloads, true);
|
||||
|
||||
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||
expect(downloads[0].arrType).toBe('radarr');
|
||||
});
|
||||
|
||||
it('skips decoration entirely when isAdmin is false', async () => {
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian'
|
||||
}
|
||||
];
|
||||
|
||||
// No nocks are set up, so any HTTP calls would throw or error
|
||||
await decorateDownloadsWithArrLinks(downloads, false);
|
||||
|
||||
expect(downloads[0].arrLink).toBeUndefined();
|
||||
expect(downloads[0].arrType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty downloads array gracefully', async () => {
|
||||
// No mock setups needed, should complete without throwing
|
||||
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||
// Mock Sonarr series query throwing connection error
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.replyWithError('connection refused');
|
||||
|
||||
// Mock Radarr movie query throwing timeout error
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.replyWithError('timeout');
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian'
|
||||
}
|
||||
];
|
||||
|
||||
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||
|
||||
// No links decorated since the fetch failed
|
||||
expect(downloads[0].arrLink).toBeUndefined();
|
||||
expect(downloads[0].arrType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('Rate Limiting Integration Tests', () => {
|
||||
let app;
|
||||
let originalSkipRateLimit;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Save current rate limiting skip flag
|
||||
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||
// Explicitly delete it before loading the app so rate limiters are active
|
||||
delete process.env.SKIP_RATE_LIMIT;
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
|
||||
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||
const appModule = await import('../../server/app.js');
|
||||
const createApp = appModule.createApp;
|
||||
|
||||
// Create a new app instance with rate limiting enabled
|
||||
app = createApp({ skipRateLimits: false });
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore rate limit skip flag
|
||||
if (originalSkipRateLimit !== undefined) {
|
||||
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||
} else {
|
||||
delete process.env.SKIP_RATE_LIMIT;
|
||||
}
|
||||
delete process.env.EMBY_URL;
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||
// count toward the rate limit window of 10 requests.
|
||||
nock('https://emby.test')
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(401, { error: 'Unauthorized' })
|
||||
.persist();
|
||||
|
||||
// Fire 10 rapid failed login requests (the limit is 10)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
}
|
||||
|
||||
// The 11th request must be rate limited and return 429
|
||||
const limitRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||
|
||||
expect(limitRes.status).toBe(429);
|
||||
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,8 @@ describe('QBittorrentClient', () => {
|
||||
downloaded: 750000000,
|
||||
speed: 1048576,
|
||||
eta: 3600,
|
||||
seeds: 0,
|
||||
peers: 0,
|
||||
category: 'movies',
|
||||
tags: ['movie', 'hd'],
|
||||
savePath: '/downloads/test',
|
||||
@@ -138,6 +140,28 @@ describe('QBittorrentClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => {
|
||||
const torrent = {
|
||||
hash: 'def456',
|
||||
name: 'Swarm Torrent',
|
||||
state: 'downloading',
|
||||
progress: 0.1,
|
||||
size: 1000,
|
||||
completed: 100,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
num_seeds: 7,
|
||||
num_leechs: 3,
|
||||
// Swarm totals — must NOT be picked up as connected counts
|
||||
num_complete: 200,
|
||||
num_incomplete: 50
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.seeds).toBe(7);
|
||||
expect(normalized.peers).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle unknown torrent states', () => {
|
||||
const torrent = {
|
||||
hash: 'abc123',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
// Tests for the shared `buildArrQueueCache` helper (Issue #61).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildArrQueueCache } = require('../../../server/utils/arrQueueHelpers');
|
||||
|
||||
const sonarrInstances = [
|
||||
{ id: 's1', url: 'http://sonarr-1', apiKey: 'KEY_S1', name: 'Sonarr 1' }
|
||||
];
|
||||
const radarrInstances = [
|
||||
{ id: 'r1', url: 'http://radarr-1', apiKey: 'KEY_R1', name: 'Radarr 1' }
|
||||
];
|
||||
|
||||
describe('buildArrQueueCache', () => {
|
||||
it('returns empty array for empty / missing input', () => {
|
||||
expect(buildArrQueueCache([], sonarrInstances, 'series')).toEqual([]);
|
||||
expect(buildArrQueueCache(null, sonarrInstances, 'series')).toEqual([]);
|
||||
expect(buildArrQueueCache(undefined, sonarrInstances, 'series')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for invalid mediaKey', () => {
|
||||
const queues = [{ instance: 's1', data: { records: [{ id: 1 }] } }];
|
||||
expect(buildArrQueueCache(queues, sonarrInstances, 'bogus')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tags Sonarr records with _instanceUrl/_instanceKey and decorates embedded series', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 's1',
|
||||
data: {
|
||||
records: [
|
||||
{ id: 1, downloadId: 'dl-1', series: { id: 100, title: 'X' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]._instanceUrl).toBe('http://sonarr-1');
|
||||
expect(out[0]._instanceKey).toBe('KEY_S1');
|
||||
expect(out[0].series._instanceUrl).toBe('http://sonarr-1');
|
||||
});
|
||||
|
||||
it('tags Radarr records and decorates embedded movie', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 'r1',
|
||||
data: {
|
||||
records: [
|
||||
{ id: 11, downloadId: 'dl-r1', movie: { id: 555, title: 'M' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]._instanceUrl).toBe('http://radarr-1');
|
||||
expect(out[0]._instanceKey).toBe('KEY_R1');
|
||||
expect(out[0].movie._instanceUrl).toBe('http://radarr-1');
|
||||
});
|
||||
|
||||
it('annotates Sonarr season pack records (multiple entries sharing downloadId)', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 's1',
|
||||
data: {
|
||||
records: [
|
||||
{ id: 1, downloadId: 'pack-A', episodeId: 101 },
|
||||
{ id: 2, downloadId: 'pack-A', episodeId: 102 },
|
||||
{ id: 3, downloadId: 'pack-A', episodeId: 103 },
|
||||
{ id: 4, downloadId: 'single-B', episodeId: 200 }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||
expect(out).toHaveLength(4);
|
||||
const packMembers = out.filter(r => r.downloadId === 'pack-A');
|
||||
expect(packMembers).toHaveLength(3);
|
||||
for (const r of packMembers) {
|
||||
expect(r.isSeasonPack).toBe(true);
|
||||
expect(r.episodeCount).toBe(3);
|
||||
}
|
||||
const single = out.find(r => r.downloadId === 'single-B');
|
||||
expect(single.isSeasonPack).toBeUndefined();
|
||||
expect(single.episodeCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not annotate Radarr records as season packs even if downloadId repeats', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 'r1',
|
||||
data: {
|
||||
records: [
|
||||
{ id: 1, downloadId: 'dup', movie: { id: 1 } },
|
||||
{ id: 2, downloadId: 'dup', movie: { id: 2 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||
expect(out).toHaveLength(2);
|
||||
for (const r of out) {
|
||||
expect(r.isSeasonPack).toBeUndefined();
|
||||
expect(r.episodeCount).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips malformed records and continues', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 's1',
|
||||
data: {
|
||||
records: [
|
||||
null,
|
||||
{ id: 1, downloadId: 'ok', series: { id: 1 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
{ instance: 's1' } // no data property
|
||||
];
|
||||
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('handles unknown instance id gracefully (null url/key)', () => {
|
||||
const queues = [
|
||||
{
|
||||
instance: 'unknown-instance',
|
||||
data: { records: [{ id: 1, downloadId: 'x' }] }
|
||||
}
|
||||
];
|
||||
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]._instanceUrl).toBeNull();
|
||||
expect(out[0]._instanceKey).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Set environment variables before requiring any modules
|
||||
process.env.POLL_INTERVAL = '5000';
|
||||
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||
|
||||
const cache = require('../../../server/utils/cache.js');
|
||||
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||
const config = require('../../../server/utils/config.js');
|
||||
|
||||
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||
sabnzbd: [],
|
||||
qbittorrent: []
|
||||
});
|
||||
|
||||
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||
movie: [],
|
||||
tv: []
|
||||
});
|
||||
|
||||
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||
|
||||
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||
lastGlobalWebhookTimestamp: null
|
||||
});
|
||||
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||
|
||||
// Now require the poller
|
||||
const poller = require('../../../server/utils/poller.js');
|
||||
|
||||
describe('Background Poller Utility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-apply standard resolved values
|
||||
initializeClientsSpy.mockResolvedValue(true);
|
||||
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||
|
||||
getSonarrInstancesSpy.mockReturnValue([]);
|
||||
getRadarrInstancesSpy.mockReturnValue([]);
|
||||
getOmbiInstancesSpy.mockReturnValue([]);
|
||||
|
||||
cacheSetSpy.mockImplementation(() => {});
|
||||
cacheGetSpy.mockReturnValue(null);
|
||||
getWebhookMetricsSpy.mockReturnValue(null);
|
||||
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
poller.stopPoller();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Poller Core Logic', () => {
|
||||
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||
expect(poller.POLLING_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||
let callbackFired = false;
|
||||
const callback = () => {
|
||||
callbackFired = true;
|
||||
};
|
||||
|
||||
poller.onPollComplete(callback);
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(callbackFired).toBe(true);
|
||||
|
||||
// Clean up/Deregister callback
|
||||
poller.offPollComplete(callback);
|
||||
callbackFired = false;
|
||||
|
||||
await poller.pollAllServices();
|
||||
expect(callbackFired).toBe(false);
|
||||
});
|
||||
|
||||
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||
// Stub initializeClients to delay using a promise
|
||||
let resolveInit;
|
||||
const delayPromise = new Promise((resolve) => {
|
||||
resolveInit = resolve;
|
||||
});
|
||||
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||
|
||||
// Start the first poll (which remains pending on initializeClients)
|
||||
const firstPollPromise = poller.pollAllServices();
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Trigger second poll immediately while first is in progress
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||
|
||||
// Resolve the delay to let the first poll finish
|
||||
resolveInit();
|
||||
await firstPollPromise;
|
||||
});
|
||||
|
||||
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||
|
||||
// Setup error spy
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||
|
||||
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||
initializeClientsSpy.mockResolvedValue(true);
|
||||
await poller.pollAllServices();
|
||||
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook-Based Instance Bypassing', () => {
|
||||
it('skips polling for an instance with recent active webhook events', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||
|
||||
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||
const recentTimestamp = Date.now() - 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Verify that skips are incremented for both
|
||||
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||
|
||||
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
|
||||
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||
const staleTimestamp = Date.now() - 11 * 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Should bypass the skip and perform a full poll
|
||||
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
|
||||
// Mock recent metrics on individual level but stale globally
|
||||
const recentTimestamp = Date.now() - 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Global webhook is stale
|
||||
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||
poller.startPoller();
|
||||
|
||||
// Triggered immediately on start (flush microtasks)
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time by 5000ms
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance by another 5000ms
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
poller.stopPoller();
|
||||
});
|
||||
|
||||
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||
poller.startPoller();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
poller.stopPoller();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user