This commit is contained in:
Admin9705
2026-02-19 11:49:30 -05:00
parent ca17636c31
commit 2b8d5306f4
11 changed files with 336 additions and 248 deletions

View File

@@ -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 ────────────────────────────────────── */

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 = '<option value="">Loading instances...</option>';
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('') :
`<div class="reqservices-empty">
<i class="${icon}"></i>
<p>No ${title.toLowerCase()} services configured</p>
<p style="font-size:0.8rem;">Add an instance to enable ${title.toLowerCase()} requests</p>
</div>`;
_renderSection(title, type, services, iconClass) {
const iconColor = type === 'movies' ? '#eab308' : '#818cf8';
const gridId = `reqservices-${type}-grid`;
const addLabel = `Add ${title} Server`;
return `<div class="reqservices-section">
<div class="reqservices-section-header">
<h3 class="reqservices-section-title"><i class="${icon}" style="color:${type === 'movies' ? '#eab308' : '#818cf8'};"></i> ${title}</h3>
<button class="requsers-btn requsers-btn-primary requsers-btn-sm" onclick="RequestarrServices.openAddModal('${type}')"><i class="fas fa-plus"></i> Add ${title} Server</button>
// 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 += `<div class="add-instance-card" data-action="add" data-type="${type}">` +
`<div class="add-icon"><i class="fas fa-plus-circle"></i></div>` +
`<div class="add-text">${addLabel}</div></div>`;
return `<div class="settings-group instances-settings-group reqservices-group">
<div class="profiles-header">
<div>
<h3><i class="fas ${iconClass}" style="color:${iconColor};margin-right:8px;"></i>${title}</h3>
<p class="profiles-help">Configure your ${title.toLowerCase()} servers below. You can connect multiple servers and mark defaults.</p>
</div>
</div>
<p class="reqservices-section-desc">Configure your ${title.toLowerCase()} server${services.length !== 1 ? 's' : ''} below. You can connect multiple servers and mark defaults.</p>
${cardsHtml}
<div class="instance-card-grid instances-card-grid" id="${gridId}">${cardsHtml}</div>
</div>`;
},
@@ -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('<span class="reqservices-badge reqservices-badge-default">Default</span>');
if (service.is_4k) badges.push('<span class="reqservices-badge reqservices-badge-4k">4K</span>');
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 `<div class="reqservices-card" data-service-id="${service.id}">
<div class="reqservices-card-left">
<div class="reqservices-card-icon ${type}"><i class="fas ${type === 'movies' ? 'fa-film' : 'fa-tv'}"></i></div>
<div class="reqservices-card-info">
<span class="reqservices-card-name">${this._esc(service.instance_name)}</span>
<span class="reqservices-card-type">${appLabel}</span>
</div>
<div class="reqservices-card-badges">${badges.join('')}</div>
const defaultBadge = isDefault ? ' <span class="default-badge">Default</span>' : '';
const fourKBadge = is4k ? ' <span class="reqservices-badge-4k-inline">4K</span>' : '';
// Default button only shown on non-default cards (like Media Hunt Instances)
const defaultBtn = isDefault ? '' : `<button type="button" class="btn-card set-default" data-id="${service.id}"><i class="fas fa-star"></i> Default</button>`;
const fourKBtn = is4k
? `<button type="button" class="btn-card reqsvc-un4k" data-id="${service.id}" title="Remove 4K flag"><span style="font-weight:700;color:#eab308;">4K</span></button>`
: `<button type="button" class="btn-card reqsvc-set4k" data-id="${service.id}" title="Mark as 4K"><span style="font-weight:700;color:var(--text-dim);">4K</span></button>`;
const deleteBtn = `<button type="button" class="btn-card delete" data-id="${service.id}"><i class="fas fa-trash"></i> Delete</button>`;
return `<div class="instance-card${isDefault ? ' default-instance' : ''}" data-service-id="${service.id}">
<div class="instance-card-header">
<span class="instance-name"><i class="fas ${iconClass}" style="margin-right:8px;"></i>${this._esc(service.instance_name)}${defaultBadge}${fourKBadge}</span>
<div class="instance-status-icon ${statusClass}"><i class="fas ${statusIcon}"></i></div>
</div>
<div class="reqservices-card-actions">
<button class="requsers-btn requsers-btn-sm" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrServices.toggleDefault(${service.id}, ${!service.is_default})" title="${service.is_default ? 'Remove default' : 'Set as default'}">
<i class="fas fa-star" style="color:${service.is_default ? '#22c55e' : 'var(--text-dim)'};"></i>
</button>
<button class="requsers-btn requsers-btn-sm" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrServices.toggle4K(${service.id}, ${!service.is_4k})" title="${service.is_4k ? 'Remove 4K flag' : 'Mark as 4K'}">
<span style="font-weight:700;font-size:0.75rem;color:${service.is_4k ? '#eab308' : 'var(--text-dim)'};">4K</span>
</button>
<button class="requsers-btn requsers-btn-danger requsers-btn-sm" onclick="RequestarrServices.removeService(${service.id})" title="Remove"><i class="fas fa-trash"></i></button>
<div class="instance-card-body">
<div class="instance-detail"><i class="fas fa-server"></i><span>${appLabel}</span></div>
</div>
<div class="instance-card-footer">${defaultBtn}${fourKBtn}${deleteBtn}</div>
</div>`;
},
_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}`));

View File

@@ -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 = '<option value="">Loading instances...</option>';
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 => ({

View File

@@ -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('') :
`<div class="reqservices-empty">
<i class="${icon}"></i>
<p>No ${title.toLowerCase()} services configured</p>
<p style="font-size:0.8rem;">Add an instance to enable ${title.toLowerCase()} requests</p>
</div>`;
_renderSection(title, type, services, iconClass) {
const iconColor = type === 'movies' ? '#eab308' : '#818cf8';
const gridId = `reqservices-${type}-grid`;
const addLabel = `Add ${title} Server`;
return `<div class="reqservices-section">
<div class="reqservices-section-header">
<h3 class="reqservices-section-title"><i class="${icon}" style="color:${type === 'movies' ? '#eab308' : '#818cf8'};"></i> ${title}</h3>
<button class="requsers-btn requsers-btn-primary requsers-btn-sm" onclick="RequestarrServices.openAddModal('${type}')"><i class="fas fa-plus"></i> Add ${title} Server</button>
// 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 += `<div class="add-instance-card" data-action="add" data-type="${type}">` +
`<div class="add-icon"><i class="fas fa-plus-circle"></i></div>` +
`<div class="add-text">${addLabel}</div></div>`;
return `<div class="settings-group instances-settings-group reqservices-group">
<div class="profiles-header">
<div>
<h3><i class="fas ${iconClass}" style="color:${iconColor};margin-right:8px;"></i>${title}</h3>
<p class="profiles-help">Configure your ${title.toLowerCase()} servers below. You can connect multiple servers and mark defaults.</p>
</div>
</div>
<p class="reqservices-section-desc">Configure your ${title.toLowerCase()} server${services.length !== 1 ? 's' : ''} below. You can connect multiple servers and mark defaults.</p>
${cardsHtml}
<div class="instance-card-grid instances-card-grid" id="${gridId}">${cardsHtml}</div>
</div>`;
},
@@ -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('<span class="reqservices-badge reqservices-badge-default">Default</span>');
if (service.is_4k) badges.push('<span class="reqservices-badge reqservices-badge-4k">4K</span>');
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 `<div class="reqservices-card" data-service-id="${service.id}">
<div class="reqservices-card-left">
<div class="reqservices-card-icon ${type}"><i class="fas ${type === 'movies' ? 'fa-film' : 'fa-tv'}"></i></div>
<div class="reqservices-card-info">
<span class="reqservices-card-name">${this._esc(service.instance_name)}</span>
<span class="reqservices-card-type">${appLabel}</span>
</div>
<div class="reqservices-card-badges">${badges.join('')}</div>
const defaultBadge = isDefault ? ' <span class="default-badge">Default</span>' : '';
const fourKBadge = is4k ? ' <span class="reqservices-badge-4k-inline">4K</span>' : '';
// Default button only shown on non-default cards (like Media Hunt Instances)
const defaultBtn = isDefault ? '' : `<button type="button" class="btn-card set-default" data-id="${service.id}"><i class="fas fa-star"></i> Default</button>`;
const fourKBtn = is4k
? `<button type="button" class="btn-card reqsvc-un4k" data-id="${service.id}" title="Remove 4K flag"><span style="font-weight:700;color:#eab308;">4K</span></button>`
: `<button type="button" class="btn-card reqsvc-set4k" data-id="${service.id}" title="Mark as 4K"><span style="font-weight:700;color:var(--text-dim);">4K</span></button>`;
const deleteBtn = `<button type="button" class="btn-card delete" data-id="${service.id}"><i class="fas fa-trash"></i> Delete</button>`;
return `<div class="instance-card${isDefault ? ' default-instance' : ''}" data-service-id="${service.id}">
<div class="instance-card-header">
<span class="instance-name"><i class="fas ${iconClass}" style="margin-right:8px;"></i>${this._esc(service.instance_name)}${defaultBadge}${fourKBadge}</span>
<div class="instance-status-icon ${statusClass}"><i class="fas ${statusIcon}"></i></div>
</div>
<div class="reqservices-card-actions">
<button class="requsers-btn requsers-btn-sm" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrServices.toggleDefault(${service.id}, ${!service.is_default})" title="${service.is_default ? 'Remove default' : 'Set as default'}">
<i class="fas fa-star" style="color:${service.is_default ? '#22c55e' : 'var(--text-dim)'};"></i>
</button>
<button class="requsers-btn requsers-btn-sm" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrServices.toggle4K(${service.id}, ${!service.is_4k})" title="${service.is_4k ? 'Remove 4K flag' : 'Mark as 4K'}">
<span style="font-weight:700;font-size:0.75rem;color:${service.is_4k ? '#eab308' : 'var(--text-dim)'};">4K</span>
</button>
<button class="requsers-btn requsers-btn-danger requsers-btn-sm" onclick="RequestarrServices.removeService(${service.id})" title="Remove"><i class="fas fa-trash"></i></button>
<div class="instance-card-body">
<div class="instance-detail"><i class="fas fa-server"></i><span>${appLabel}</span></div>
</div>
<div class="instance-card-footer">${defaultBtn}${fourKBtn}${deleteBtn}</div>
</div>`;
},
_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}`));

View File

@@ -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({

View File

@@ -1,9 +1,9 @@
<!-- Bundled scripts (run: python scripts/build_js_bundles.py to rebuild) -->
<script defer src="./static/js/dist/bundle-core.js?v=15"></script>
<script defer src="./static/js/dist/bundle-app.js?v=29"></script>
<script defer src="./static/js/dist/bundle-app.js?v=30"></script>
<script defer src="./static/js/dist/bundle-settings.js?v=41"></script>
<script defer src="./static/js/dist/bundle-features.js?v=19"></script>
<script defer src="./static/js/dist/bundle-media.js?v=23"></script>
<script defer src="./static/js/dist/bundle-misc.js?v=25"></script>
<!-- Requestarr (Vite-bundled, minified) -->
<script defer src="./static/js/dist/requestarr-bundle.js?v=32"></script>
<script defer src="./static/js/dist/requestarr-bundle.js?v=37"></script>

View File

@@ -47,27 +47,38 @@
<div class="nav-icon-wrapper"><i class="fas fa-eye-slash"></i></div>
<span>Hidden Media</span>
</a>
<a href="./#requestarr-requests" class="nav-item nav-item-sub" id="requestarrRequestsNav">
<div class="nav-icon-wrapper"><i class="fas fa-inbox"></i></div>
<span>Requests</span>
<span class="nav-badge" id="requestarr-pending-badge" style="display:none;">0</span>
</a>
<a href="./#requestarr-smarthunt-settings" class="nav-item nav-item-sub" id="requestarrSmartHuntSettingsNav">
<div class="nav-icon-wrapper"><i class="fas fa-fire"></i></div>
<span>Smart Hunt</span>
</a>
<a href="./#requestarr-users" class="nav-item nav-item-sub" id="requestarrUsersNav">
<div class="nav-icon-wrapper"><i class="fas fa-users"></i></div>
<span>Users</span>
</a>
<a href="./#requestarr-services" class="nav-item nav-item-sub" id="requestarrServicesNav">
<div class="nav-icon-wrapper"><i class="fas fa-server"></i></div>
<span>Services</span>
</a>
<a href="./#requestarr-settings" class="nav-item nav-item-sub" id="requestarrSettingsNav">
<div class="nav-icon-wrapper"><i class="fas fa-cog"></i></div>
<span>Settings</span>
</a>
<a href="./#requestarr-users" class="nav-item nav-item-sub" id="requestarrUserSupportToggle">
<div class="nav-icon-wrapper"><i class="fas fa-headset"></i></div>
<span>User Support</span>
<span class="nav-badge" id="requestarr-pending-badge" style="display:none;">0</span>
</a>
<div class="nav-sub-group" id="requestarr-user-support-sub">
<a href="./#requestarr-discover" class="nav-item nav-item-sub requestarr-user-support-back" id="requestarrUserSupportBackNav">
<div class="nav-icon-wrapper"><i class="fas fa-arrow-left"></i></div>
<span>Back</span>
</a>
<a href="./#requestarr-services" class="nav-item nav-item-sub" id="requestarrServicesNav">
<div class="nav-icon-wrapper"><i class="fas fa-server"></i></div>
<span>Support Instances</span>
</a>
<a href="./#requestarr-users" class="nav-item nav-item-sub" id="requestarrUsersNav">
<div class="nav-icon-wrapper"><i class="fas fa-users"></i></div>
<span>Users</span>
</a>
<a href="./#requestarr-requests" class="nav-item nav-item-sub" id="requestarrRequestsNav">
<div class="nav-icon-wrapper"><i class="fas fa-inbox"></i></div>
<span>Requests</span>
<span class="nav-badge requestarr-pending-badge-mirror" style="display:none;">0</span>
</a>
</div>
</div>
</div>
@@ -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'; }

View File

@@ -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