From 2b8d5306f4bdb60000ccddecb67c19e9d4e7262b Mon Sep 17 00:00:00 2001 From: Admin9705 <9705@duck.com> Date: Thu, 19 Feb 2026 11:49:30 -0500 Subject: [PATCH] Refactor --- frontend/static/css/requestarr-users.css | 162 ++++++------------ frontend/static/css/sidebar.css | 24 ++- frontend/static/js/app.js | 19 +- frontend/static/js/dist/bundle-app.js | 19 +- frontend/static/js/dist/requestarr-bundle.js | 138 ++++++++++----- .../features/requestarr/requestarr-content.js | 14 +- .../requestarr/requestarr-services.js | 110 ++++++++---- .../requestarr/requestarr-settings.js | 14 +- frontend/templates/components/scripts.html | 4 +- frontend/templates/components/sidebar.html | 48 ++++-- src/primary/utils/database.py | 32 +++- 11 files changed, 336 insertions(+), 248 deletions(-) diff --git a/frontend/static/css/requestarr-users.css b/frontend/static/css/requestarr-users.css index fc57f78e..f0f2c804 100644 --- a/frontend/static/css/requestarr-users.css +++ b/frontend/static/css/requestarr-users.css @@ -337,130 +337,70 @@ /* ── Services Page ────────────────────────────────────────── */ -.reqservices-section { - margin-bottom: 32px; +/* ── Services Page (uses shared instance-card system from instances-card.css) ── */ +/* Matches Media Hunt Instances bordered-section design exactly */ + +.reqservices-group { + background: rgba(15, 23, 42, 0.4); + border: 1px solid rgba(148, 163, 184, 0.08); + border-radius: 12px; + padding: 24px; + margin: 12px 0 20px 0; } -.reqservices-section-header { +.reqservices-group .profiles-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - margin-bottom: 12px; + gap: 16px; + margin-bottom: 4px; } -.reqservices-section-title { - font-size: 1.15rem; - font-weight: 600; - color: var(--text-primary); +.reqservices-group .profiles-header h3 { margin: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.reqservices-section-desc { - font-size: 0.85rem; - color: var(--text-muted); - margin-bottom: 16px; -} - -.reqservices-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: var(--radius-md); - margin-bottom: 8px; - backdrop-filter: blur(var(--glass-blur)); -} - -.reqservices-card-left { - display: flex; - align-items: center; - gap: 12px; -} - -.reqservices-card-icon { - width: 36px; - height: 36px; - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - font-size: 1rem; -} - -.reqservices-card-icon.movies { - background: rgba(234, 179, 8, 0.12); - color: #eab308; -} - -.reqservices-card-icon.tv { - background: rgba(99, 102, 241, 0.12); - color: #818cf8; -} - -.reqservices-card-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.reqservices-card-name { - font-weight: 500; - color: var(--text-primary); - font-size: 0.9rem; -} - -.reqservices-card-type { - font-size: 0.78rem; - color: var(--text-muted); -} - -.reqservices-card-badges { - display: flex; - gap: 6px; - align-items: center; -} - -.reqservices-badge { - padding: 2px 8px; - border-radius: 10px; - font-size: 0.7rem; + padding: 0 0 12px 0; + font-size: 1.1rem; font-weight: 600; + color: #f1f5f9; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); } -.reqservices-badge-default { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; +.reqservices-group .profiles-help { + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + margin: 12px 0 0; } -.reqservices-badge-4k { - background: rgba(234, 179, 8, 0.15); +.reqservices-group .instances-card-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; + margin-top: 16px; + margin-bottom: 0; +} + +.reqservices-group .instances-card-grid .add-instance-card { + min-height: 100px; +} + +.reqservices-group .instances-card-grid .add-instance-card .add-icon { + font-size: 1.5rem; +} + +.reqservices-group .instances-card-grid .add-instance-card .add-text { + font-size: 0.85rem; +} + +/* 4K inline badge (next to instance name in card header) */ +.reqservices-badge-4k-inline { + font-size: 0.65rem; + background: rgba(234, 179, 8, 0.25); color: #eab308; -} - -.reqservices-card-actions { - display: flex; - gap: 6px; - align-items: center; -} - -.reqservices-empty { - padding: 32px; - text-align: center; - color: var(--text-muted); - border: 2px dashed var(--border-color); - border-radius: var(--radius-lg); -} - -.reqservices-empty i { - font-size: 2rem; - margin-bottom: 8px; - display: block; - opacity: 0.5; + padding: 2px 7px; + border-radius: 12px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.5px; + margin-left: 6px; } /* ── Plex Import Modal ────────────────────────────────────── */ diff --git a/frontend/static/css/sidebar.css b/frontend/static/css/sidebar.css index 27f22609..6c103640 100644 --- a/frontend/static/css/sidebar.css +++ b/frontend/static/css/sidebar.css @@ -382,7 +382,8 @@ #movie-hunt-activity-sub, #media-hunt-config-sub, #index-master-sub, -#index-master-view-sub { +#index-master-view-sub, +#requestarr-user-support-sub { display: none; margin-top: 0; } @@ -391,13 +392,15 @@ #movie-hunt-activity-sub.expanded, #media-hunt-config-sub.expanded, #index-master-sub.expanded, -#index-master-view-sub.expanded { +#index-master-view-sub.expanded, +#requestarr-user-support-sub.expanded { display: block; } #movie-hunt-collection-sub .nav-item-sub, #movie-hunt-activity-sub .nav-item-sub, #media-hunt-config-sub .nav-item-sub, +#requestarr-user-support-sub .nav-item-sub, #index-master-sub .nav-item-sub, #index-master-view-sub .nav-item-sub { margin-top: 0; @@ -427,14 +430,27 @@ } /* Back button styling in config sub-group */ -.media-hunt-config-back { +.media-hunt-config-back, +.requestarr-user-support-back { opacity: 0.7; font-size: 0.85em; } -.media-hunt-config-back:hover { +.media-hunt-config-back:hover, +.requestarr-user-support-back:hover { opacity: 1; } +/* When user-support sub-group is expanded, hide main Requests items + other toggles */ +#sidebar-group-requests.user-support-view > #requestarrDiscoverNav, +#sidebar-group-requests.user-support-view > #requestarrTVNav, +#sidebar-group-requests.user-support-view > #requestarrMoviesNav, +#sidebar-group-requests.user-support-view > #requestarrHiddenNav, +#sidebar-group-requests.user-support-view > #requestarrSmartHuntSettingsNav, +#sidebar-group-requests.user-support-view > #requestarrSettingsNav, +#sidebar-group-requests.user-support-view > #requestarrUserSupportToggle { + display: none !important; +} + /* ===== APP ICONS ===== */ .app-icon-img { width: 20px; diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index 68947850..13041023 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -186,7 +186,7 @@ let huntarrUI = { } else if (this.currentSection === 'movie-hunt-home' || this.currentSection === 'movie-hunt-collection' || this.currentSection === 'media-hunt-collection' || this.currentSection === 'activity-queue' || this.currentSection === 'activity-history' || this.currentSection === 'activity-blocklist' || this.currentSection === 'activity-logs' || this.currentSection === 'logs-media-hunt' || this.currentSection === 'settings-clients' || this.currentSection === 'movie-hunt-instance-editor') { console.log('[huntarrUI] Initialization - showing movie hunt sidebar'); this.showMovieHuntSidebar(); - } else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-discover' || this.currentSection === 'requestarr-movies' || this.currentSection === 'requestarr-tv' || this.currentSection === 'requestarr-hidden' || this.currentSection === 'requestarr-settings' || this.currentSection === 'requestarr-smarthunt-settings' || this.currentSection === 'requestarr-users' || this.currentSection === 'requestarr-services') { + } else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-discover' || this.currentSection === 'requestarr-movies' || this.currentSection === 'requestarr-tv' || this.currentSection === 'requestarr-hidden' || this.currentSection === 'requestarr-settings' || this.currentSection === 'requestarr-smarthunt-settings' || this.currentSection === 'requestarr-users' || this.currentSection === 'requestarr-services' || this.currentSection === 'requestarr-requests') { if (this._enableRequestarr === false) { console.log('[huntarrUI] Requestarr disabled - redirecting to home'); this.switchSection('home'); @@ -294,14 +294,18 @@ let huntarrUI = { .then(r => r.ok ? r.json() : null) .then(data => { var badge = document.getElementById('requestarr-pending-badge'); - if (!badge) return; + var mirrors = document.querySelectorAll('.requestarr-pending-badge-mirror'); var count = (data && data.count) || 0; - if (count > 0) { - badge.textContent = count > 99 ? '99+' : String(count); - badge.style.display = ''; - } else { - badge.style.display = 'none'; + var text = count > 99 ? '99+' : String(count); + var show = count > 0; + if (badge) { + badge.textContent = text; + badge.style.display = show ? '' : 'none'; } + mirrors.forEach(function(m) { + m.textContent = text; + m.style.display = show ? '' : 'none'; + }); }) .catch(function() {}); }, @@ -347,6 +351,7 @@ let huntarrUI = { 'requestarrUsersNav', 'requestarrServicesNav', 'requestarrSettingsNav', + 'requestarrUserSupportToggle', ]; hideNavItems.forEach(function(id) { var el = document.getElementById(id); diff --git a/frontend/static/js/dist/bundle-app.js b/frontend/static/js/dist/bundle-app.js index 7a52d846..a3660747 100644 --- a/frontend/static/js/dist/bundle-app.js +++ b/frontend/static/js/dist/bundle-app.js @@ -188,7 +188,7 @@ let huntarrUI = { } else if (this.currentSection === 'movie-hunt-home' || this.currentSection === 'movie-hunt-collection' || this.currentSection === 'media-hunt-collection' || this.currentSection === 'activity-queue' || this.currentSection === 'activity-history' || this.currentSection === 'activity-blocklist' || this.currentSection === 'activity-logs' || this.currentSection === 'logs-media-hunt' || this.currentSection === 'settings-clients' || this.currentSection === 'movie-hunt-instance-editor') { console.log('[huntarrUI] Initialization - showing movie hunt sidebar'); this.showMovieHuntSidebar(); - } else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-discover' || this.currentSection === 'requestarr-movies' || this.currentSection === 'requestarr-tv' || this.currentSection === 'requestarr-hidden' || this.currentSection === 'requestarr-settings' || this.currentSection === 'requestarr-smarthunt-settings' || this.currentSection === 'requestarr-users' || this.currentSection === 'requestarr-services') { + } else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-discover' || this.currentSection === 'requestarr-movies' || this.currentSection === 'requestarr-tv' || this.currentSection === 'requestarr-hidden' || this.currentSection === 'requestarr-settings' || this.currentSection === 'requestarr-smarthunt-settings' || this.currentSection === 'requestarr-users' || this.currentSection === 'requestarr-services' || this.currentSection === 'requestarr-requests') { if (this._enableRequestarr === false) { console.log('[huntarrUI] Requestarr disabled - redirecting to home'); this.switchSection('home'); @@ -296,14 +296,18 @@ let huntarrUI = { .then(r => r.ok ? r.json() : null) .then(data => { var badge = document.getElementById('requestarr-pending-badge'); - if (!badge) return; + var mirrors = document.querySelectorAll('.requestarr-pending-badge-mirror'); var count = (data && data.count) || 0; - if (count > 0) { - badge.textContent = count > 99 ? '99+' : String(count); - badge.style.display = ''; - } else { - badge.style.display = 'none'; + var text = count > 99 ? '99+' : String(count); + var show = count > 0; + if (badge) { + badge.textContent = text; + badge.style.display = show ? '' : 'none'; } + mirrors.forEach(function(m) { + m.textContent = text; + m.style.display = show ? '' : 'none'; + }); }) .catch(function() {}); }, @@ -349,6 +353,7 @@ let huntarrUI = { 'requestarrUsersNav', 'requestarrServicesNav', 'requestarrSettingsNav', + 'requestarrUserSupportToggle', ]; hideNavItems.forEach(function(id) { var el = document.getElementById(id); diff --git a/frontend/static/js/dist/requestarr-bundle.js b/frontend/static/js/dist/requestarr-bundle.js index d4958d95..8ed6e32a 100644 --- a/frontend/static/js/dist/requestarr-bundle.js +++ b/frontend/static/js/dist/requestarr-bundle.js @@ -1686,14 +1686,16 @@ class RequestarrSettings { try { const _ts = Date.now(); - const [movieHuntResponse, radarrResponse, sonarrResponse] = await Promise.all([ + const [movieHuntResponse, radarrResponse, tvHuntResponse, sonarrResponse] = await Promise.all([ fetch(`./api/requestarr/instances/movie_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/radarr?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); const movieHuntData = await movieHuntResponse.json(); const radarrData = await radarrResponse.json(); + const tvHuntData = await tvHuntResponse.json(); const sonarrData = await sonarrResponse.json(); const instanceOptions = []; @@ -1717,6 +1719,16 @@ class RequestarrSettings { } }); + // TV Hunt instances + (tvHuntData.instances || []).forEach(instance => { + if (instance && instance.name) { + instanceOptions.push({ + value: `tv_hunt::${instance.name}`, + label: `TV Hunt \u2013 ${instance.name}` + }); + } + }); + (sonarrData.instances || []).forEach(instance => { if (instance && instance.name) { instanceOptions.push({ @@ -3497,12 +3509,10 @@ class RequestarrContent { try { const _ts = Date.now(); const [thResponse, sonarrResponse] = await Promise.all([ - fetch(`./api/tv-hunt/instances?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); - let thData = await thResponse.json(); - if (!thResponse.ok || thData.error) thData = { instances: [] }; - else thData = { instances: (thData.instances || []).filter(i => i.enabled !== false) }; + const thData = await thResponse.json(); const sonarrData = await sonarrResponse.json(); const allInstances = [ @@ -3775,15 +3785,13 @@ class RequestarrContent { select.innerHTML = ''; try { - // Fetch TV Hunt from Media Hunt API (canonical); Sonarr from requestarr + // Fetch TV Hunt and Sonarr from requestarr (filtered by services config) const _ts = Date.now(); const [thResponse, sonarrResponse] = await Promise.all([ - fetch(`./api/tv-hunt/instances?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); - let thData = await thResponse.json(); - if (!thResponse.ok || thData.error) thData = { instances: [] }; - else thData = { instances: (thData.instances || []).filter(i => i.enabled !== false) }; + const thData = await thResponse.json(); const sonarrData = await sonarrResponse.json(); const thInstances = (thData.instances || []).map(inst => ({ @@ -7456,6 +7464,8 @@ window.RequestarrUsers = { * Manages which instances are available for media requests. * Movies = Radarr + Movie Hunt instances * TV = Sonarr + TV Hunt instances + * + * Uses the same instance-card design system as Media Hunt Instances. */ window.RequestarrServices = { @@ -7495,27 +7505,38 @@ window.RequestarrServices = { const movieServices = this.services.filter(s => s.service_type === 'movies'); const tvServices = this.services.filter(s => s.service_type === 'tv'); - container.innerHTML = ` - ${this._renderSection('Movies', 'movies', movieServices, 'fas fa-film')} - ${this._renderSection('TV', 'tv', tvServices, 'fas fa-tv')} - `; + container.innerHTML = + this._renderSection('Movies', 'movies', movieServices, 'fa-film') + + this._renderSection('TV', 'tv', tvServices, 'fa-tv'); + + // Wire up click handlers on the grids + this._wireGrid('reqservices-movies-grid', 'movies'); + this._wireGrid('reqservices-tv-grid', 'tv'); }, - _renderSection(title, type, services, icon) { - const cardsHtml = services.length ? services.map(s => this._renderCard(s, type)).join('') : - `
- -

No ${title.toLowerCase()} services configured

-

Add an instance to enable ${title.toLowerCase()} requests

-
`; + _renderSection(title, type, services, iconClass) { + const iconColor = type === 'movies' ? '#eab308' : '#818cf8'; + const gridId = `reqservices-${type}-grid`; + const addLabel = `Add ${title} Server`; - return `
-
-

${title}

- + // Sort: default first (moves to the left) + services.sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0)); + + let cardsHtml = ''; + services.forEach(s => { cardsHtml += this._renderCard(s, type); }); + // Add-instance card (dashed) + cardsHtml += `
` + + `
` + + `
${addLabel}
`; + + return `
+
+
+

${title}

+

Configure your ${title.toLowerCase()} servers below. You can connect multiple servers and mark defaults.

+
-

Configure your ${title.toLowerCase()} server${services.length !== 1 ? 's' : ''} below. You can connect multiple servers and mark defaults.

- ${cardsHtml} +
${cardsHtml}
`; }, @@ -7525,34 +7546,57 @@ window.RequestarrServices = { movie_hunt: 'Movie Hunt', tv_hunt: 'TV Hunt' }[service.app_type] || service.app_type; - const badges = []; - if (service.is_default) badges.push('Default'); - if (service.is_4k) badges.push('4K'); + const iconClass = type === 'movies' ? 'fa-film' : 'fa-tv'; + const isDefault = !!service.is_default; + const is4k = !!service.is_4k; + const statusClass = 'status-connected'; + const statusIcon = 'fa-check-circle'; - return `
-
-
-
- ${this._esc(service.instance_name)} - ${appLabel} -
-
${badges.join('')}
+ const defaultBadge = isDefault ? ' Default' : ''; + const fourKBadge = is4k ? ' 4K' : ''; + + // Default button only shown on non-default cards (like Media Hunt Instances) + const defaultBtn = isDefault ? '' : ``; + const fourKBtn = is4k + ? `` + : ``; + const deleteBtn = ``; + + return `
+
+ ${this._esc(service.instance_name)}${defaultBadge}${fourKBadge} +
-
- - - +
+
${appLabel}
+
`; }, + _wireGrid(gridId, type) { + const grid = document.getElementById(gridId); + if (!grid) return; + grid.addEventListener('click', (e) => { + const addCard = e.target.closest('.add-instance-card[data-action="add"]'); + if (addCard) { e.preventDefault(); this.openAddModal(type); return; } + + const defaultBtn = e.target.closest('.btn-card.set-default'); + if (defaultBtn) { e.stopPropagation(); this.toggleDefault(parseInt(defaultBtn.dataset.id), true); return; } + + const set4kBtn = e.target.closest('.btn-card.reqsvc-set4k'); + if (set4kBtn) { e.stopPropagation(); this.toggle4K(parseInt(set4kBtn.dataset.id), true); return; } + + const un4kBtn = e.target.closest('.btn-card.reqsvc-un4k'); + if (un4kBtn) { e.stopPropagation(); this.toggle4K(parseInt(un4kBtn.dataset.id), false); return; } + + const deleteBtn = e.target.closest('.btn-card.delete'); + if (deleteBtn) { e.stopPropagation(); this.removeService(parseInt(deleteBtn.dataset.id)); return; } + }); + }, + openAddModal(serviceType) { const available = (serviceType === 'movies') ? (this.available.movies || []) : (this.available.tv || []); - // Filter out already-added instances const existing = new Set(this.services.filter(s => s.service_type === serviceType).map(s => `${s.app_type}:${s.instance_name}`)); const filtered = available.filter(a => !existing.has(`${a.app_type}:${a.instance_name}`)); diff --git a/frontend/static/js/modules/features/requestarr/requestarr-content.js b/frontend/static/js/modules/features/requestarr/requestarr-content.js index 69775064..b8c915fd 100644 --- a/frontend/static/js/modules/features/requestarr/requestarr-content.js +++ b/frontend/static/js/modules/features/requestarr/requestarr-content.js @@ -249,12 +249,10 @@ export class RequestarrContent { try { const _ts = Date.now(); const [thResponse, sonarrResponse] = await Promise.all([ - fetch(`./api/tv-hunt/instances?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); - let thData = await thResponse.json(); - if (!thResponse.ok || thData.error) thData = { instances: [] }; - else thData = { instances: (thData.instances || []).filter(i => i.enabled !== false) }; + const thData = await thResponse.json(); const sonarrData = await sonarrResponse.json(); const allInstances = [ @@ -527,15 +525,13 @@ export class RequestarrContent { select.innerHTML = ''; try { - // Fetch TV Hunt from Media Hunt API (canonical); Sonarr from requestarr + // Fetch TV Hunt and Sonarr from requestarr (filtered by services config) const _ts = Date.now(); const [thResponse, sonarrResponse] = await Promise.all([ - fetch(`./api/tv-hunt/instances?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); - let thData = await thResponse.json(); - if (!thResponse.ok || thData.error) thData = { instances: [] }; - else thData = { instances: (thData.instances || []).filter(i => i.enabled !== false) }; + const thData = await thResponse.json(); const sonarrData = await sonarrResponse.json(); const thInstances = (thData.instances || []).map(inst => ({ diff --git a/frontend/static/js/modules/features/requestarr/requestarr-services.js b/frontend/static/js/modules/features/requestarr/requestarr-services.js index 226c6971..651062ad 100644 --- a/frontend/static/js/modules/features/requestarr/requestarr-services.js +++ b/frontend/static/js/modules/features/requestarr/requestarr-services.js @@ -3,6 +3,8 @@ * Manages which instances are available for media requests. * Movies = Radarr + Movie Hunt instances * TV = Sonarr + TV Hunt instances + * + * Uses the same instance-card design system as Media Hunt Instances. */ window.RequestarrServices = { @@ -42,27 +44,38 @@ window.RequestarrServices = { const movieServices = this.services.filter(s => s.service_type === 'movies'); const tvServices = this.services.filter(s => s.service_type === 'tv'); - container.innerHTML = ` - ${this._renderSection('Movies', 'movies', movieServices, 'fas fa-film')} - ${this._renderSection('TV', 'tv', tvServices, 'fas fa-tv')} - `; + container.innerHTML = + this._renderSection('Movies', 'movies', movieServices, 'fa-film') + + this._renderSection('TV', 'tv', tvServices, 'fa-tv'); + + // Wire up click handlers on the grids + this._wireGrid('reqservices-movies-grid', 'movies'); + this._wireGrid('reqservices-tv-grid', 'tv'); }, - _renderSection(title, type, services, icon) { - const cardsHtml = services.length ? services.map(s => this._renderCard(s, type)).join('') : - `
- -

No ${title.toLowerCase()} services configured

-

Add an instance to enable ${title.toLowerCase()} requests

-
`; + _renderSection(title, type, services, iconClass) { + const iconColor = type === 'movies' ? '#eab308' : '#818cf8'; + const gridId = `reqservices-${type}-grid`; + const addLabel = `Add ${title} Server`; - return `
-
-

${title}

- + // Sort: default first (moves to the left) + services.sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0)); + + let cardsHtml = ''; + services.forEach(s => { cardsHtml += this._renderCard(s, type); }); + // Add-instance card (dashed) + cardsHtml += `
` + + `
` + + `
${addLabel}
`; + + return `
+
+
+

${title}

+

Configure your ${title.toLowerCase()} servers below. You can connect multiple servers and mark defaults.

+
-

Configure your ${title.toLowerCase()} server${services.length !== 1 ? 's' : ''} below. You can connect multiple servers and mark defaults.

- ${cardsHtml} +
${cardsHtml}
`; }, @@ -72,34 +85,57 @@ window.RequestarrServices = { movie_hunt: 'Movie Hunt', tv_hunt: 'TV Hunt' }[service.app_type] || service.app_type; - const badges = []; - if (service.is_default) badges.push('Default'); - if (service.is_4k) badges.push('4K'); + const iconClass = type === 'movies' ? 'fa-film' : 'fa-tv'; + const isDefault = !!service.is_default; + const is4k = !!service.is_4k; + const statusClass = 'status-connected'; + const statusIcon = 'fa-check-circle'; - return `
-
-
-
- ${this._esc(service.instance_name)} - ${appLabel} -
-
${badges.join('')}
+ const defaultBadge = isDefault ? ' Default' : ''; + const fourKBadge = is4k ? ' 4K' : ''; + + // Default button only shown on non-default cards (like Media Hunt Instances) + const defaultBtn = isDefault ? '' : ``; + const fourKBtn = is4k + ? `` + : ``; + const deleteBtn = ``; + + return `
+
+ ${this._esc(service.instance_name)}${defaultBadge}${fourKBadge} +
-
- - - +
+
${appLabel}
+
`; }, + _wireGrid(gridId, type) { + const grid = document.getElementById(gridId); + if (!grid) return; + grid.addEventListener('click', (e) => { + const addCard = e.target.closest('.add-instance-card[data-action="add"]'); + if (addCard) { e.preventDefault(); this.openAddModal(type); return; } + + const defaultBtn = e.target.closest('.btn-card.set-default'); + if (defaultBtn) { e.stopPropagation(); this.toggleDefault(parseInt(defaultBtn.dataset.id), true); return; } + + const set4kBtn = e.target.closest('.btn-card.reqsvc-set4k'); + if (set4kBtn) { e.stopPropagation(); this.toggle4K(parseInt(set4kBtn.dataset.id), true); return; } + + const un4kBtn = e.target.closest('.btn-card.reqsvc-un4k'); + if (un4kBtn) { e.stopPropagation(); this.toggle4K(parseInt(un4kBtn.dataset.id), false); return; } + + const deleteBtn = e.target.closest('.btn-card.delete'); + if (deleteBtn) { e.stopPropagation(); this.removeService(parseInt(deleteBtn.dataset.id)); return; } + }); + }, + openAddModal(serviceType) { const available = (serviceType === 'movies') ? (this.available.movies || []) : (this.available.tv || []); - // Filter out already-added instances const existing = new Set(this.services.filter(s => s.service_type === serviceType).map(s => `${s.app_type}:${s.instance_name}`)); const filtered = available.filter(a => !existing.has(`${a.app_type}:${a.instance_name}`)); diff --git a/frontend/static/js/modules/features/requestarr/requestarr-settings.js b/frontend/static/js/modules/features/requestarr/requestarr-settings.js index 85ed9296..319a6354 100644 --- a/frontend/static/js/modules/features/requestarr/requestarr-settings.js +++ b/frontend/static/js/modules/features/requestarr/requestarr-settings.js @@ -203,14 +203,16 @@ export class RequestarrSettings { try { const _ts = Date.now(); - const [movieHuntResponse, radarrResponse, sonarrResponse] = await Promise.all([ + const [movieHuntResponse, radarrResponse, tvHuntResponse, sonarrResponse] = await Promise.all([ fetch(`./api/requestarr/instances/movie_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/radarr?t=${_ts}`, { cache: 'no-store' }), + fetch(`./api/requestarr/instances/tv_hunt?t=${_ts}`, { cache: 'no-store' }), fetch(`./api/requestarr/instances/sonarr?t=${_ts}`, { cache: 'no-store' }) ]); const movieHuntData = await movieHuntResponse.json(); const radarrData = await radarrResponse.json(); + const tvHuntData = await tvHuntResponse.json(); const sonarrData = await sonarrResponse.json(); const instanceOptions = []; @@ -234,6 +236,16 @@ export class RequestarrSettings { } }); + // TV Hunt instances + (tvHuntData.instances || []).forEach(instance => { + if (instance && instance.name) { + instanceOptions.push({ + value: `tv_hunt::${instance.name}`, + label: `TV Hunt \u2013 ${instance.name}` + }); + } + }); + (sonarrData.instances || []).forEach(instance => { if (instance && instance.name) { instanceOptions.push({ diff --git a/frontend/templates/components/scripts.html b/frontend/templates/components/scripts.html index 3c5ad639..7f8ff2f2 100644 --- a/frontend/templates/components/scripts.html +++ b/frontend/templates/components/scripts.html @@ -1,9 +1,9 @@ - + - + diff --git a/frontend/templates/components/sidebar.html b/frontend/templates/components/sidebar.html index 9ddbe4a7..8f295182 100644 --- a/frontend/templates/components/sidebar.html +++ b/frontend/templates/components/sidebar.html @@ -47,27 +47,38 @@ Hidden Media - - - Requests - - Smart Hunt - - - Users - - - - Services - Settings + + + User Support + + +
@@ -365,7 +376,8 @@ var ALL_SUB_GROUP_IDS = [ 'movie-hunt-activity-sub', 'media-hunt-config-sub', 'index-master-sub', - 'index-master-view-sub' + 'index-master-view-sub', + 'requestarr-user-support-sub' ]; // ── Expand / collapse helpers ──────────────────────────────── @@ -448,6 +460,8 @@ function setActiveNavItem() { var h = window.location.hash || '#home'; var mhBody = document.getElementById('sidebar-group-media-hunt'); if (mhBody) { mhBody.classList.remove('config-view'); mhBody.classList.remove('activity-view'); mhBody.classList.remove('indexmaster-view'); } + var reqBody = document.getElementById('sidebar-group-requests'); + if (reqBody) { reqBody.classList.remove('user-support-view'); } var selector = null; var group = null; @@ -487,11 +501,11 @@ function setActiveNavItem() { else if (h === '#requestarr-tv') { selector = '#requestarrTVNav'; group = 'sidebar-group-requests'; } else if (h === '#requestarr-movies') { selector = '#requestarrMoviesNav'; group = 'sidebar-group-requests'; } else if (h === '#requestarr-hidden') { selector = '#requestarrHiddenNav'; group = 'sidebar-group-requests'; } - else if (h === '#requestarr-requests') { selector = '#requestarrRequestsNav'; group = 'sidebar-group-requests'; } else if (h === '#requestarr-smarthunt-settings') { selector = '#requestarrSmartHuntSettingsNav'; group = 'sidebar-group-requests'; } - else if (h === '#requestarr-users') { selector = '#requestarrUsersNav'; group = 'sidebar-group-requests'; } - else if (h === '#requestarr-services') { selector = '#requestarrServicesNav'; group = 'sidebar-group-requests'; } else if (h === '#requestarr-settings') { selector = '#requestarrSettingsNav'; group = 'sidebar-group-requests'; } + else if (h === '#requestarr-users') { selector = '#requestarrUsersNav'; group = 'sidebar-group-requests'; subToExpand = 'requestarr-user-support-sub'; if (reqBody) reqBody.classList.add('user-support-view'); } + else if (h === '#requestarr-requests') { selector = '#requestarrRequestsNav'; group = 'sidebar-group-requests'; subToExpand = 'requestarr-user-support-sub'; if (reqBody) reqBody.classList.add('user-support-view'); } + else if (h === '#requestarr-services') { selector = '#requestarrServicesNav'; group = 'sidebar-group-requests'; subToExpand = 'requestarr-user-support-sub'; if (reqBody) reqBody.classList.add('user-support-view'); } // ── Apps ────────────────────────────────────── else if (h === '#apps' || h === '#sonarr' || h === '#instance-editor') { selector = '#appsSonarrNav'; group = 'sidebar-group-apps'; } diff --git a/src/primary/utils/database.py b/src/primary/utils/database.py index 93df7fce..00715315 100644 --- a/src/primary/utils/database.py +++ b/src/primary/utils/database.py @@ -2913,9 +2913,21 @@ class HuntarrDatabase: def add_requestarr_service(self, service_type: str, app_type: str, instance_name: str, instance_id: int = None, is_default: bool = False, is_4k: bool = False) -> bool: - """Add an instance as a requestarr service.""" + """Add an instance as a requestarr service. First in a section auto-becomes default. Only one default per service_type.""" try: with self.get_connection() as conn: + # If no services exist yet for this type, force default + count = conn.execute( + 'SELECT COUNT(*) FROM requestarr_services WHERE service_type = ?', (service_type,) + ).fetchone()[0] + if count == 0: + is_default = True + # If marking as default, clear other defaults in the same service_type + if is_default: + conn.execute( + 'UPDATE requestarr_services SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE service_type = ?', + (service_type,) + ) conn.execute(''' INSERT OR REPLACE INTO requestarr_services (service_type, app_type, instance_name, instance_id, is_default, is_4k, enabled, created_at, updated_at) @@ -2939,17 +2951,25 @@ class HuntarrDatabase: return False def update_requestarr_service(self, service_id: int, updates: Dict[str, Any]) -> bool: - """Update a requestarr service.""" + """Update a requestarr service. Only one default per service_type.""" try: allowed = {'is_default', 'is_4k', 'enabled'} filtered = {k: v for k, v in updates.items() if k in allowed} if not filtered: return True - set_parts = [f'{k} = ?' for k in filtered] - set_parts.append('updated_at = CURRENT_TIMESTAMP') - values = list(filtered.values()) + [service_id] - sql = f"UPDATE requestarr_services SET {', '.join(set_parts)} WHERE id = ?" with self.get_connection() as conn: + # If setting as default, clear other defaults in the same service_type first + if filtered.get('is_default') and int(filtered['is_default']): + row = conn.execute('SELECT service_type FROM requestarr_services WHERE id = ?', (service_id,)).fetchone() + if row: + conn.execute( + 'UPDATE requestarr_services SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE service_type = ? AND id != ?', + (row['service_type'], service_id) + ) + set_parts = [f'{k} = ?' for k in filtered] + set_parts.append('updated_at = CURRENT_TIMESTAMP') + values = list(filtered.values()) + [service_id] + sql = f"UPDATE requestarr_services SET {', '.join(set_parts)} WHERE id = ?" conn.execute(sql, values) conn.commit() return True