mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2026-04-20 11:06:53 -04:00
10377 lines
504 KiB
JavaScript
10377 lines
504 KiB
JavaScript
|
||
/* === modules/features/requestarr/requestarr-detail.js === */
|
||
/**
|
||
* Requestarr Movie Detail Page – Uses shared mh-* styling from Movie Hunt
|
||
* Handles Radarr + Movie Hunt instances and movie status checking
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Delegate to shared MediaUtils (loaded before this file)
|
||
function _encodeInstanceValue(appType, name) {
|
||
return window.MediaUtils.encodeInstanceValue(appType, name);
|
||
}
|
||
function _decodeInstanceValue(value) {
|
||
return window.MediaUtils.decodeInstanceValue(value);
|
||
}
|
||
|
||
window.RequestarrDetail = {
|
||
currentMovie: null,
|
||
tmdbApiKey: null,
|
||
movieInstances: [], // Combined Movie Hunt + Radarr instances
|
||
selectedInstanceName: null, // Compound value: "movie_hunt:Name" or "radarr:Name"
|
||
|
||
init() {
|
||
console.log('[RequestarrDetail] Module initialized');
|
||
|
||
window.addEventListener('popstate', (e) => {
|
||
if (e.state && e.state.requestarrMovieDetail) {
|
||
this.openDetail(e.state.requestarrMovieDetail, e.state.options || {}, true);
|
||
} else {
|
||
this.closeDetail(true);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('hashchange', () => {
|
||
const hash = window.location.hash || '';
|
||
const m = hash.match(/^#requestarr-movie\/(\d+)$/);
|
||
if (m) {
|
||
const tmdbId = parseInt(m[1], 10);
|
||
this.openDetail({ id: tmdbId, tmdb_id: tmdbId }, {}, true);
|
||
} else {
|
||
this.closeDetail(true);
|
||
}
|
||
});
|
||
|
||
// Restore detail on refresh when URL has #requestarr-movie/ID
|
||
const hash = window.location.hash || '';
|
||
const m = hash.match(/^#requestarr-movie\/(\d+)$/);
|
||
if (m) {
|
||
const tmdbId = parseInt(m[1], 10);
|
||
this.openDetail({ id: tmdbId, tmdb_id: tmdbId }, {}, true);
|
||
}
|
||
},
|
||
|
||
async openDetail(movie, options = {}, fromHistory = false) {
|
||
if (!movie) return;
|
||
|
||
this.currentMovie = movie;
|
||
this.options = options || {};
|
||
console.log('[RequestarrDetail] Opening detail for:', movie.title);
|
||
|
||
if (this.movieInstances.length === 0) {
|
||
await this.loadMovieInstances();
|
||
}
|
||
|
||
let detailView = document.getElementById('requestarr-detail-view');
|
||
if (!detailView) {
|
||
detailView = document.createElement('div');
|
||
detailView.id = 'requestarr-detail-view';
|
||
detailView.className = 'movie-detail-view';
|
||
document.body.appendChild(detailView);
|
||
}
|
||
|
||
detailView.innerHTML = this.getLoadingHTML();
|
||
detailView.classList.add('active');
|
||
|
||
if (!fromHistory) {
|
||
const tmdbId = movie.tmdb_id || movie.id;
|
||
const url = `${window.location.pathname}${window.location.search}#requestarr-movie/${tmdbId}`;
|
||
history.pushState(
|
||
{ requestarrMovieDetail: movie, options: this.options },
|
||
movie.title,
|
||
url
|
||
);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
const backBtn = document.getElementById('requestarr-detail-back-loading');
|
||
if (backBtn) {
|
||
backBtn.addEventListener('click', () => this.closeDetail());
|
||
}
|
||
}, 0);
|
||
|
||
try {
|
||
const tmdbId = movie.tmdb_id || movie.id;
|
||
const details = await this.fetchMovieDetails(tmdbId);
|
||
|
||
if (details) {
|
||
detailView.innerHTML = this.renderMovieDetail(details, movie);
|
||
this.setupDetailInteractions();
|
||
} else {
|
||
detailView.innerHTML = this.getErrorHTML('Failed to load movie details');
|
||
this.setupErrorBackButton();
|
||
}
|
||
} catch (error) {
|
||
console.error('[RequestarrDetail] Error loading details:', error);
|
||
detailView.innerHTML = this.getErrorHTML('Failed to load movie details');
|
||
this.setupErrorBackButton();
|
||
}
|
||
},
|
||
|
||
closeDetail(fromHistory = false) {
|
||
const detailView = document.getElementById('requestarr-detail-view');
|
||
if (detailView) {
|
||
detailView.classList.remove('active');
|
||
}
|
||
|
||
// Remove ESC listener to prevent stacking
|
||
if (this._escHandler) {
|
||
document.removeEventListener('keydown', this._escHandler);
|
||
this._escHandler = null;
|
||
}
|
||
|
||
if (!fromHistory && /^#requestarr-movie\//.test(window.location.hash || '')) {
|
||
history.back();
|
||
}
|
||
},
|
||
|
||
async fetchMovieDetails(tmdbId) {
|
||
if (!tmdbId) return null;
|
||
|
||
try {
|
||
const response = await fetch(`./api/movie-hunt/tmdb-movie/${tmdbId}`);
|
||
if (!response.ok) throw new Error(`TMDB API returned ${response.status}`);
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error('[RequestarrDetail] Error fetching movie details:', error);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
async loadMovieInstances() {
|
||
try {
|
||
// Fetch both Movie Hunt and Radarr instances in parallel
|
||
const [mhResponse, radarrResponse] = await Promise.all([
|
||
fetch('./api/requestarr/instances/movie_hunt'),
|
||
fetch('./api/requestarr/instances/radarr')
|
||
]);
|
||
const mhData = await mhResponse.json();
|
||
const radarrData = await radarrResponse.json();
|
||
|
||
const combined = [];
|
||
|
||
// Movie Hunt instances first (include id for edit/delete)
|
||
if (mhData.instances) {
|
||
mhData.instances.forEach(function(inst) {
|
||
combined.push({
|
||
name: inst.name,
|
||
id: inst.id,
|
||
appType: 'movie_hunt',
|
||
compoundValue: _encodeInstanceValue('movie_hunt', inst.name),
|
||
label: 'Movie Hunt \u2013 ' + inst.name
|
||
});
|
||
});
|
||
}
|
||
|
||
// Then Radarr instances
|
||
if (radarrData.instances) {
|
||
radarrData.instances.forEach(function(inst) {
|
||
combined.push({
|
||
name: inst.name,
|
||
appType: 'radarr',
|
||
compoundValue: _encodeInstanceValue('radarr', inst.name),
|
||
label: 'Radarr \u2013 ' + inst.name
|
||
});
|
||
});
|
||
}
|
||
|
||
this.movieInstances = combined;
|
||
|
||
if (combined.length > 0) {
|
||
if (this.options.suggestedInstance) {
|
||
this.selectedInstanceName = this.options.suggestedInstance;
|
||
} else if (!this.selectedInstanceName) {
|
||
this.selectedInstanceName = combined[0].compoundValue;
|
||
}
|
||
} else {
|
||
this.movieInstances = [];
|
||
this.selectedInstanceName = null;
|
||
}
|
||
} catch (error) {
|
||
console.error('[RequestarrDetail] Error loading movie instances:', error);
|
||
this.movieInstances = [];
|
||
this.selectedInstanceName = null;
|
||
}
|
||
},
|
||
|
||
async checkMovieStatus(tmdbId, instanceValue) {
|
||
if (!instanceValue) return { in_library: false, found: false, previously_requested: false };
|
||
|
||
try {
|
||
// Decode compound value to get app type and actual name
|
||
var decoded = _decodeInstanceValue(instanceValue);
|
||
var appTypeParam = decoded.appType === 'movie_hunt' ? '&app_type=movie_hunt' : '';
|
||
var response = await fetch('./api/requestarr/movie-status?tmdb_id=' + tmdbId + '&instance=' + encodeURIComponent(decoded.name) + appTypeParam);
|
||
var data = await response.json();
|
||
|
||
return {
|
||
in_library: data.in_library || false,
|
||
found: data.found || false,
|
||
previously_requested: data.previously_requested || false
|
||
};
|
||
} catch (error) {
|
||
console.error('[RequestarrDetail] Error checking movie status:', error);
|
||
return { in_library: false, found: false, previously_requested: false };
|
||
}
|
||
},
|
||
|
||
// updateMovieStatus — removed (no-op). Status is driven by updateDetailInfoBar().
|
||
|
||
/**
|
||
* Check if there's an existing request for this movie and update the action button.
|
||
*/
|
||
async _checkRequestStatus() {
|
||
if (!this.currentMovie) return;
|
||
const tmdbId = this.currentMovie.tmdb_id || this.currentMovie.id;
|
||
if (!tmdbId) return;
|
||
try {
|
||
const resp = await fetch(`./api/requestarr/requests/check/movie/${tmdbId}`, { cache: 'no-store' });
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (data.exists && data.request) {
|
||
const btn = document.getElementById('requestarr-detail-request-btn');
|
||
if (!btn) return;
|
||
const status = data.request.status;
|
||
if (status === 'pending') {
|
||
btn.innerHTML = '<i class="fas fa-clock"></i> Request Pending';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-warning');
|
||
btn.disabled = true;
|
||
} else if (status === 'approved') {
|
||
btn.innerHTML = '<i class="fas fa-check-circle"></i> Request Approved';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-success');
|
||
btn.disabled = true;
|
||
} else if (status === 'denied') {
|
||
// Denied — allow re-request
|
||
btn.innerHTML = '<i class="fas fa-times-circle"></i> Denied — Re-request';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-denied');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.debug('[RequestarrDetail] Request status check skipped:', e);
|
||
}
|
||
},
|
||
|
||
formatFileSize(bytes) {
|
||
if (!bytes || bytes === 0) return '0 B';
|
||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||
return (bytes / 1024).toFixed(0) + ' KB';
|
||
},
|
||
|
||
getToolbarHTML(isMovieHunt) {
|
||
if (isMovieHunt) {
|
||
return '<div class="mh-toolbar" id="requestarr-detail-toolbar">' +
|
||
'<div class="mh-toolbar-left">' +
|
||
// Shown when IN collection:
|
||
'<button class="mh-tb" id="requestarr-detail-refresh" title="Refresh" style="display:none"><i class="fas fa-redo-alt"></i><span>Refresh</span></button>' +
|
||
'<span id="requestarr-detail-force-container"></span>' +
|
||
// Shown when NOT in collection:
|
||
'<button class="mh-tb" id="requestarr-detail-search-movie" title="Search Movie" style="display:none"><i class="fas fa-search"></i><span>Search Movie</span></button>' +
|
||
'</div>' +
|
||
'<div class="mh-toolbar-right">' +
|
||
// Shown when IN collection:
|
||
'<button class="mh-tb" id="requestarr-detail-edit" title="Edit" style="display:none"><i class="fas fa-wrench"></i><span>Edit</span></button>' +
|
||
'<button class="mh-tb mh-tb-danger" id="requestarr-detail-delete" title="Delete" style="display:none"><i class="fas fa-trash-alt"></i></button>' +
|
||
// Shown when NOT in collection:
|
||
'<button class="mh-tb" id="requestarr-detail-hide" title="Hide from discovery" style="display:none"><i class="fas fa-eye-slash"></i></button>' +
|
||
'</div></div>';
|
||
}
|
||
return '<div class="mh-toolbar" id="requestarr-detail-toolbar">' +
|
||
'<div class="mh-toolbar-left">' +
|
||
'</div><div class="mh-toolbar-right"></div></div>';
|
||
},
|
||
|
||
replaceAndAttachToolbar(isMovieHunt) {
|
||
var toolbarEl = document.getElementById('requestarr-detail-toolbar');
|
||
if (!toolbarEl) return;
|
||
toolbarEl.outerHTML = this.getToolbarHTML(isMovieHunt);
|
||
this.attachToolbarHandlers();
|
||
},
|
||
|
||
attachToolbarHandlers() {
|
||
var self = this;
|
||
var backBtn = document.getElementById('requestarr-detail-back');
|
||
if (backBtn) backBtn.addEventListener('click', () => this.closeDetail());
|
||
var refreshBtn = document.getElementById('requestarr-detail-refresh');
|
||
if (refreshBtn) refreshBtn.addEventListener('click', async () => {
|
||
if (refreshBtn.disabled) return;
|
||
refreshBtn.disabled = true;
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Refresh scan initiated.', 'success');
|
||
}
|
||
try {
|
||
await this.updateDetailInfoBar(true);
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Refresh failed.', 'error');
|
||
}
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
}
|
||
});
|
||
var editBtn = document.getElementById('requestarr-detail-edit');
|
||
if (editBtn) editBtn.addEventListener('click', () => this.openEditModalForMovieHunt());
|
||
var deleteBtn = document.getElementById('requestarr-detail-delete');
|
||
if (deleteBtn) deleteBtn.addEventListener('click', () => this.openDeleteModalForMovieHunt());
|
||
|
||
// Monitor toggle
|
||
var monitorBtn = document.getElementById('requestarr-movie-monitor-btn');
|
||
if (monitorBtn) monitorBtn.addEventListener('click', function() { self.toggleMovieMonitor(); });
|
||
|
||
// Search Movie (request) — for items NOT in collection
|
||
var searchMovieBtn = document.getElementById('requestarr-detail-search-movie');
|
||
if (searchMovieBtn) searchMovieBtn.addEventListener('click', function() {
|
||
if (self.currentMovie && window.RequestarrDiscover && window.RequestarrDiscover.modal) {
|
||
window.RequestarrDiscover.modal.openModal(self.currentMovie.tmdb_id || self.currentMovie.id, 'movie', self.selectedInstanceName);
|
||
}
|
||
});
|
||
|
||
// Hide from discovery — for items NOT in collection
|
||
var hideBtn = document.getElementById('requestarr-detail-hide');
|
||
if (hideBtn) hideBtn.addEventListener('click', function() {
|
||
if (!self.currentMovie || !window.MediaUtils) return;
|
||
var decoded = _decodeInstanceValue(self.selectedInstanceName || '');
|
||
window.MediaUtils.hideMedia({
|
||
tmdbId: self.currentMovie.tmdb_id || self.currentMovie.id,
|
||
mediaType: 'movie',
|
||
title: self.currentMovie.title || 'this movie',
|
||
posterPath: self.currentMovie.poster_path || null,
|
||
appType: decoded.appType || 'movie_hunt',
|
||
instanceName: decoded.name || '',
|
||
cardElement: null,
|
||
onHidden: function() {
|
||
self.closeDetail();
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
async openEditModalForMovieHunt() {
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'movie_hunt' || !decoded.name) return;
|
||
var inst = this.movieInstances.find(function(i) { return i.compoundValue === this.selectedInstanceName; }.bind(this));
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) return;
|
||
|
||
var movie = this.currentMovie;
|
||
var status = this.currentMovieStatusForMH || null;
|
||
if (!movie) return;
|
||
|
||
var title = this.escapeHtml(movie.title || '');
|
||
|
||
var profiles = [], rootFolders = [];
|
||
try {
|
||
var [profResp, rfResp] = await Promise.all([
|
||
fetch('./api/profiles?instance_id=' + instanceId),
|
||
fetch('./api/movie-hunt/root-folders?instance_id=' + instanceId)
|
||
]);
|
||
var profData = await profResp.json();
|
||
profiles = profData.profiles || profData || [];
|
||
var rfData = await rfResp.json();
|
||
rootFolders = rfData.root_folders || rfData || [];
|
||
} catch (err) {
|
||
console.error('[RequestarrDetail] Edit modal fetch error:', err);
|
||
}
|
||
|
||
var currentProfile = (status && status.quality_profile) || '';
|
||
var currentRoot = (status && status.root_folder_path) || '';
|
||
var currentAvail = (status && status.minimum_availability) || 'released';
|
||
var self = this;
|
||
|
||
var profileOpts = (Array.isArray(profiles) ? profiles : []).map(function(p) {
|
||
var name = p.name || 'Unknown';
|
||
var sel = name === currentProfile ? ' selected' : '';
|
||
return '<option value="' + self.escapeHtml(name) + '"' + sel + '>' + self.escapeHtml(name) + (p.is_default ? ' (Default)' : '') + '</option>';
|
||
}).join('');
|
||
|
||
var rfOpts = (Array.isArray(rootFolders) ? rootFolders : []).map(function(rf) {
|
||
var path = rf.path || '';
|
||
var sel = path === currentRoot ? ' selected' : '';
|
||
return '<option value="' + self.escapeHtml(path) + '"' + sel + '>' + self.escapeHtml(path) + (rf.is_default ? ' (Default)' : '') + '</option>';
|
||
}).join('');
|
||
|
||
var availOpts = [
|
||
{ value: 'announced', label: 'Announced' },
|
||
{ value: 'inCinemas', label: 'In Cinemas' },
|
||
{ value: 'released', label: 'Released' }
|
||
].map(function(a) {
|
||
var sel = a.value === currentAvail ? ' selected' : '';
|
||
return '<option value="' + a.value + '"' + sel + '>' + a.label + '</option>';
|
||
}).join('');
|
||
|
||
var html =
|
||
'<div class="mh-modal-backdrop" id="mh-edit-modal">' +
|
||
'<div class="mh-modal">' +
|
||
'<div class="mh-modal-header">' +
|
||
'<h3><i class="fas fa-wrench"></i> Edit \u2014 ' + title + '</h3>' +
|
||
'<button class="mh-modal-x" id="mh-edit-close">×</button>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-body">' +
|
||
'<div class="mh-form-row"><label>Root Folder</label><select id="mh-edit-root-folder" class="mh-select">' + rfOpts + '</select></div>' +
|
||
'<div class="mh-form-row"><label>Quality Profile</label><select id="mh-edit-quality-profile" class="mh-select">' + profileOpts + '</select></div>' +
|
||
'<div class="mh-form-row"><label>Minimum Availability</label><select id="mh-edit-min-availability" class="mh-select">' + availOpts + '</select></div>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-footer">' +
|
||
'<button class="mh-btn mh-btn-secondary" id="mh-edit-cancel">Cancel</button>' +
|
||
'<button class="mh-btn mh-btn-primary" id="mh-edit-save">Save</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
var existing = document.getElementById('mh-edit-modal');
|
||
if (existing) existing.remove();
|
||
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
|
||
document.getElementById('mh-edit-close').addEventListener('click', function() { document.getElementById('mh-edit-modal').remove(); });
|
||
document.getElementById('mh-edit-cancel').addEventListener('click', function() { document.getElementById('mh-edit-modal').remove(); });
|
||
document.getElementById('mh-edit-modal').addEventListener('click', function(e) {
|
||
if (e.target.id === 'mh-edit-modal') document.getElementById('mh-edit-modal').remove();
|
||
});
|
||
document.getElementById('mh-edit-save').addEventListener('click', function() { self._handleSaveEdit(instanceId); });
|
||
},
|
||
|
||
async _handleSaveEdit(instanceId) {
|
||
var movie = this.currentMovie;
|
||
if (!movie) return;
|
||
var tmdbId = movie.tmdb_id || movie.id;
|
||
var rootFolder = document.getElementById('mh-edit-root-folder').value;
|
||
var qualityProfile = document.getElementById('mh-edit-quality-profile').value;
|
||
var minAvailability = document.getElementById('mh-edit-min-availability').value;
|
||
var saveBtn = document.getElementById('mh-edit-save');
|
||
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; }
|
||
var self = this;
|
||
|
||
try {
|
||
var resp = await fetch('./api/movie-hunt/collection/update?instance_id=' + instanceId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tmdb_id: tmdbId, root_folder: rootFolder, quality_profile: qualityProfile, minimum_availability: minAvailability })
|
||
});
|
||
var data = await resp.json();
|
||
if (data.success) {
|
||
var modal = document.getElementById('mh-edit-modal');
|
||
if (modal) modal.remove();
|
||
self.updateDetailInfoBar();
|
||
if (window.MediaUtils) window.MediaUtils.dispatchStatusChanged(tmdbId, 'edit');
|
||
} else {
|
||
var msg = 'Save failed: ' + (data.error || 'Unknown error');
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification(msg, 'error');
|
||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
|
||
}
|
||
} catch (err) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Save failed: ' + err.message, 'error');
|
||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
|
||
}
|
||
},
|
||
|
||
openDeleteModalForMovieHunt() {
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'movie_hunt' || !decoded.name) return;
|
||
var inst = this.movieInstances.find(function(i) { return i.compoundValue === this.selectedInstanceName; }.bind(this));
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) return;
|
||
|
||
// Use shared MovieCardDeleteModal directly
|
||
if (window.MovieCardDeleteModal) {
|
||
var movie = this.currentMovie;
|
||
var status = this.currentMovieStatusForMH || null;
|
||
var hasFile = !!(status && status.has_file);
|
||
var movieStatus = hasFile ? 'available' : 'requested';
|
||
var filePath = (status && status.path) || (status && status.root_folder_path) || '';
|
||
var self = this;
|
||
window.MovieCardDeleteModal.open(movie, {
|
||
instanceName: decoded.name,
|
||
instanceId: instanceId,
|
||
status: movieStatus,
|
||
hasFile: hasFile,
|
||
filePath: filePath,
|
||
appType: 'movie_hunt',
|
||
onDeleted: function() {
|
||
self.closeDetail();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Fetches detailed movie status and updates: info bar, toolbar visibility,
|
||
* force search/upgrade button, and action button area.
|
||
* This is the single source of truth for the detail page state.
|
||
*/
|
||
async updateDetailInfoBar(forceProbe) {
|
||
var self = this;
|
||
var pathEl = document.getElementById('requestarr-ib-path');
|
||
var statusEl = document.getElementById('requestarr-ib-status');
|
||
var profileEl = document.getElementById('requestarr-ib-profile');
|
||
var sizeEl = document.getElementById('requestarr-ib-size');
|
||
if (!pathEl || !statusEl) return;
|
||
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
var tmdbId = this.currentMovie && (this.currentMovie.tmdb_id || this.currentMovie.id);
|
||
if (!tmdbId) return;
|
||
|
||
var data = null;
|
||
var isMovieHunt = decoded.appType === 'movie_hunt';
|
||
|
||
// ── Fetch status from correct API ──
|
||
if (isMovieHunt && decoded.name) {
|
||
var inst = this.movieInstances.find(function(i) { return i.compoundValue === self.selectedInstanceName; });
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) {
|
||
this._setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl);
|
||
this._updateToolbarForStatus(false, false, isMovieHunt);
|
||
return;
|
||
}
|
||
try {
|
||
var qs = 'tmdb_id=' + tmdbId + '&instance_id=' + instanceId + '&t=' + Date.now();
|
||
if (forceProbe) qs += '&force_probe=true';
|
||
var resp = await fetch('./api/movie-hunt/movie-status?' + qs);
|
||
data = await resp.json();
|
||
this.currentMovieStatusForMH = data;
|
||
} catch (err) {
|
||
console.error('[RequestarrDetail] Movie Hunt detail bar error:', err);
|
||
this.currentMovieStatusForMH = null;
|
||
this._setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl);
|
||
this._updateToolbarForStatus(false, false, isMovieHunt);
|
||
return;
|
||
}
|
||
} else if (decoded.appType === 'radarr' && decoded.name) {
|
||
try {
|
||
var resp = await fetch('./api/requestarr/movie-detail-status?tmdb_id=' + tmdbId + '&instance=' + encodeURIComponent(decoded.name) + '&t=' + Date.now());
|
||
data = await resp.json();
|
||
} catch (err) {
|
||
console.error('[RequestarrDetail] Radarr detail bar error:', err);
|
||
this._setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl);
|
||
this._updateToolbarForStatus(false, false, false);
|
||
return;
|
||
}
|
||
} else {
|
||
this._setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl);
|
||
this._updateToolbarForStatus(false, false, false);
|
||
return;
|
||
}
|
||
|
||
// ── Not found in collection ──
|
||
if (!data || !data.success || !data.found) {
|
||
this._setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl);
|
||
this._updateToolbarForStatus(false, false, isMovieHunt);
|
||
return;
|
||
}
|
||
|
||
// ── Found — update info bar ──
|
||
var displayPath = data.path || data.root_folder_path || '-';
|
||
pathEl.textContent = displayPath;
|
||
pathEl.title = displayPath;
|
||
|
||
var isDownloaded = (data.status || '').toLowerCase() === 'downloaded';
|
||
var cls = '', icon = '', label = '';
|
||
if (isDownloaded) { cls = 'mh-badge-ok'; icon = 'fa-check-circle'; label = 'Downloaded'; }
|
||
else if (data.status === 'missing') { cls = 'mh-badge-warn'; icon = 'fa-exclamation-circle'; label = 'Requested'; }
|
||
else { cls = 'mh-badge-warn'; icon = 'fa-clock'; label = 'Requested'; }
|
||
statusEl.innerHTML = '<span class="mh-badge ' + cls + '"><i class="fas ' + icon + '"></i> ' + label + '</span>';
|
||
|
||
if (profileEl) profileEl.textContent = data.quality_profile || '-';
|
||
|
||
// Size + optional file quality badge
|
||
if (sizeEl) {
|
||
if (data.file_quality) {
|
||
sizeEl.innerHTML = this.formatFileSize(data.file_size || 0) +
|
||
' <span class="mh-badge mh-badge-quality">' + this.escapeHtml(data.file_quality) + '</span>';
|
||
} else {
|
||
sizeEl.textContent = this.formatFileSize(data.file_size || 0);
|
||
}
|
||
}
|
||
|
||
// ── Row 2: Resolution, Codec, Score, Availability ──
|
||
var row2 = document.getElementById('requestarr-info-bar-row2');
|
||
if (row2) {
|
||
var resEl = document.getElementById('requestarr-ib-resolution');
|
||
var codecEl = document.getElementById('requestarr-ib-codec');
|
||
var scoreEl = document.getElementById('requestarr-ib-score');
|
||
var availEl = document.getElementById('requestarr-ib-availability');
|
||
var availMap = { 'announced': 'Announced', 'inCinemas': 'In Cinemas', 'released': 'Released' };
|
||
|
||
if (data.has_file) {
|
||
row2.style.display = '';
|
||
if (resEl) resEl.textContent = data.file_resolution || '-';
|
||
// Build codec/audio string from granular or combined data
|
||
if (codecEl) {
|
||
var codecStr = '';
|
||
if (data.file_video_codec || data.file_audio_codec) {
|
||
var parts = [];
|
||
if (data.file_video_codec) parts.push(data.file_video_codec);
|
||
if (data.file_audio_codec) {
|
||
var audioStr = data.file_audio_codec;
|
||
if (data.file_audio_channels && data.file_audio_channels !== 'Mono' && data.file_audio_channels !== 'Stereo' && data.file_audio_channels !== '0ch') {
|
||
audioStr += ' ' + data.file_audio_channels;
|
||
} else if (data.file_audio_channels) {
|
||
audioStr += ' (' + data.file_audio_channels + ')';
|
||
}
|
||
parts.push(audioStr);
|
||
}
|
||
codecStr = parts.join(' / ');
|
||
} else {
|
||
codecStr = data.file_codec || '-';
|
||
}
|
||
codecEl.textContent = codecStr || '-';
|
||
}
|
||
|
||
// Score with hover tooltip for breakdown
|
||
if (scoreEl) {
|
||
var scoreVal = data.file_score;
|
||
if (scoreVal != null) {
|
||
var scoreClass = scoreVal >= 0 ? 'mh-score-pos' : 'mh-score-neg';
|
||
var breakdown = data.file_score_breakdown || 'No custom format matches';
|
||
scoreEl.innerHTML = '<span class="mh-score-badge ' + scoreClass + '" title="' + this.escapeHtml(breakdown) + '">' + scoreVal + '</span>';
|
||
} else {
|
||
scoreEl.textContent = '-';
|
||
}
|
||
}
|
||
|
||
if (availEl) {
|
||
var avail = data.minimum_availability || 'released';
|
||
availEl.textContent = availMap[avail] || avail;
|
||
}
|
||
} else if (data.found) {
|
||
// Show row with just availability for non-downloaded but tracked movies
|
||
row2.style.display = '';
|
||
if (resEl) resEl.textContent = '-';
|
||
if (codecEl) codecEl.textContent = '-';
|
||
if (scoreEl) scoreEl.textContent = '-';
|
||
if (availEl) {
|
||
var avail = data.minimum_availability || 'released';
|
||
availEl.textContent = availMap[avail] || avail;
|
||
}
|
||
} else {
|
||
row2.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Update toolbar and action buttons ──
|
||
this._updateToolbarForStatus(true, isDownloaded, isMovieHunt, data);
|
||
},
|
||
|
||
/** Helper: set info bar to "Not in Collection" */
|
||
_setInfoBarNotFound(pathEl, statusEl, profileEl, sizeEl) {
|
||
if (pathEl) pathEl.textContent = '-';
|
||
if (statusEl) statusEl.innerHTML = '<span class="mh-badge mh-badge-none">Not in Collection</span>';
|
||
if (profileEl) profileEl.textContent = '-';
|
||
if (sizeEl) sizeEl.textContent = '-';
|
||
// Hide Row 2
|
||
var row2 = document.getElementById('requestarr-info-bar-row2');
|
||
if (row2) row2.style.display = 'none';
|
||
},
|
||
|
||
/**
|
||
* Update toolbar buttons and action area based on movie status.
|
||
*
|
||
* NOT in collection:
|
||
* Toolbar-left: Back, Search Movie (to request)
|
||
* Toolbar-right: Hide icon (eye-slash → add to hidden media)
|
||
* Action area: "Request Movie" button
|
||
*
|
||
* IN collection (requested):
|
||
* Toolbar-left: Back, Refresh, Force Search
|
||
* Toolbar-right: Edit, Delete (trash)
|
||
* Action area: empty (status bar shows state)
|
||
*
|
||
* IN collection (downloaded):
|
||
* Toolbar-left: Back, Refresh, Force Upgrade
|
||
* Toolbar-right: Edit, Delete (trash)
|
||
* Action area: empty (status bar shows state)
|
||
*/
|
||
_updateToolbarForStatus(isFound, isDownloaded, isMovieHunt, statusData) {
|
||
var self = this;
|
||
|
||
// ── Toolbar management buttons ──
|
||
var editBtn = document.getElementById('requestarr-detail-edit');
|
||
var deleteBtn = document.getElementById('requestarr-detail-delete');
|
||
var refreshBtn = document.getElementById('requestarr-detail-refresh');
|
||
|
||
// Edit, Delete, Refresh only for items in collection (Movie Hunt only)
|
||
if (editBtn) editBtn.style.display = (isFound && isMovieHunt) ? '' : 'none';
|
||
if (deleteBtn) deleteBtn.style.display = (isFound && isMovieHunt) ? '' : 'none';
|
||
if (refreshBtn) refreshBtn.style.display = (isFound && isMovieHunt) ? '' : 'none';
|
||
|
||
// ── Monitor toggle — show only when movie is in collection (Movie Hunt) ──
|
||
var monitorWrap = document.getElementById('requestarr-movie-monitor-wrap');
|
||
var monitorBtn = document.getElementById('requestarr-movie-monitor-btn');
|
||
if (monitorWrap && monitorBtn) {
|
||
if (isFound && isMovieHunt) {
|
||
monitorWrap.style.display = '';
|
||
var monitored = statusData ? statusData.monitored !== false : true;
|
||
var icon = monitorBtn.querySelector('i');
|
||
if (icon) icon.className = monitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
} else {
|
||
monitorWrap.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Hide button (eye-slash) — only when NOT in collection ──
|
||
var hideBtn = document.getElementById('requestarr-detail-hide');
|
||
if (hideBtn) hideBtn.style.display = (!isFound && isMovieHunt) ? '' : 'none';
|
||
|
||
// ── Search Movie button — only when NOT in collection ──
|
||
var searchMovieBtn = document.getElementById('requestarr-detail-search-movie');
|
||
if (searchMovieBtn) searchMovieBtn.style.display = (!isFound && isMovieHunt) ? '' : 'none';
|
||
|
||
// ── Force Search / Force Upgrade — only for Movie Hunt in collection ──
|
||
var forceContainer = document.getElementById('requestarr-detail-force-container');
|
||
if (forceContainer) {
|
||
if (!isFound || !isMovieHunt) {
|
||
forceContainer.innerHTML = '';
|
||
} else if (isDownloaded) {
|
||
forceContainer.innerHTML = '<button class="mh-tb" id="requestarr-detail-force-upgrade" title="Search for a higher-scoring release"><i class="fas fa-arrow-circle-up"></i><span>Force Upgrade</span></button>';
|
||
var upgradeBtn = document.getElementById('requestarr-detail-force-upgrade');
|
||
if (upgradeBtn) upgradeBtn.addEventListener('click', function() { self._handleForceUpgrade(); });
|
||
} else {
|
||
forceContainer.innerHTML = '<button class="mh-tb" id="requestarr-detail-force-search" title="Search indexers and download"><i class="fas fa-search"></i><span>Force Search</span></button>';
|
||
var searchBtn = document.getElementById('requestarr-detail-force-search');
|
||
if (searchBtn) searchBtn.addEventListener('click', function() { self._handleForceSearch(); });
|
||
}
|
||
}
|
||
|
||
// ── Action button area ──
|
||
var actionsContainer = document.querySelector('.mh-hero-actions');
|
||
if (actionsContainer) {
|
||
if (isFound) {
|
||
// Status bar already communicates the state
|
||
actionsContainer.innerHTML = '';
|
||
} else {
|
||
actionsContainer.innerHTML = '<button class="mh-btn mh-btn-primary" id="requestarr-detail-request-btn"><i class="fas fa-download"></i> Request Movie</button>';
|
||
var requestBtn = document.getElementById('requestarr-detail-request-btn');
|
||
if (requestBtn) {
|
||
requestBtn.addEventListener('click', function() {
|
||
if (window.RequestarrDiscover && window.RequestarrDiscover.modal) {
|
||
window.RequestarrDiscover.modal.openModal(
|
||
self.currentMovie.tmdb_id, 'movie', self.selectedInstanceName
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
async _handleForceSearch() {
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'movie_hunt' || !decoded.name) return;
|
||
var inst = this.movieInstances.find(function(i) { return i.compoundValue === this.selectedInstanceName; }.bind(this));
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) return;
|
||
|
||
var movie = this.currentMovie;
|
||
if (!movie) return;
|
||
var btn = document.getElementById('requestarr-detail-force-search');
|
||
if (btn) { btn.disabled = true; var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-spinner fa-spin'; }
|
||
|
||
var notify = function(msg, type) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification(msg, type);
|
||
};
|
||
|
||
try {
|
||
var resp = await fetch('./api/movie-hunt/request?instance_id=' + instanceId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: movie.title || '',
|
||
year: movie.year || '',
|
||
tmdb_id: movie.tmdb_id || movie.id,
|
||
poster_path: movie.poster_path || '',
|
||
start_search: true,
|
||
runtime: 90
|
||
})
|
||
});
|
||
var data = await resp.json();
|
||
if (data.success) {
|
||
notify('Search complete \u2014 ' + (data.message || 'Sent to download client.'), 'success');
|
||
} else {
|
||
notify(data.message || 'No matching release found.', 'error');
|
||
}
|
||
} catch (err) {
|
||
notify('Search failed: ' + err.message, 'error');
|
||
}
|
||
|
||
if (btn) { btn.disabled = false; var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-search'; }
|
||
this.updateDetailInfoBar();
|
||
if (window.MediaUtils) window.MediaUtils.dispatchStatusChanged(movie.tmdb_id || movie.id, 'force-search');
|
||
},
|
||
|
||
async _handleForceUpgrade() {
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'movie_hunt' || !decoded.name) return;
|
||
var inst = this.movieInstances.find(function(i) { return i.compoundValue === this.selectedInstanceName; }.bind(this));
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) return;
|
||
|
||
var movie = this.currentMovie;
|
||
var status = this.currentMovieStatusForMH || null;
|
||
if (!movie) return;
|
||
var btn = document.getElementById('requestarr-detail-force-upgrade');
|
||
if (btn) { btn.disabled = true; var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-spinner fa-spin'; }
|
||
|
||
var notify = function(msg, type) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification(msg, type);
|
||
};
|
||
|
||
try {
|
||
var currentScore = (status && status.file_score != null) ? status.file_score : 0;
|
||
var resp = await fetch('./api/movie-hunt/force-upgrade?instance_id=' + instanceId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: movie.title || '',
|
||
year: movie.year || '',
|
||
tmdb_id: movie.tmdb_id || movie.id,
|
||
current_score: currentScore,
|
||
quality_profile: (status && status.quality_profile) || '',
|
||
runtime: 90
|
||
})
|
||
});
|
||
var data = await resp.json();
|
||
if (data.success) {
|
||
notify(data.message || 'Upgrade sent to download client.', 'success');
|
||
} else {
|
||
notify(data.message || 'No higher-scoring release available.', 'info');
|
||
}
|
||
} catch (err) {
|
||
notify('Upgrade search failed: ' + err.message, 'error');
|
||
}
|
||
|
||
if (btn) { btn.disabled = false; var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-arrow-circle-up'; }
|
||
this.updateDetailInfoBar();
|
||
if (window.MediaUtils) window.MediaUtils.dispatchStatusChanged(movie.tmdb_id || movie.id, 'force-upgrade');
|
||
},
|
||
|
||
async toggleMovieMonitor() {
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'movie_hunt') return;
|
||
var inst = this.movieInstances.find(i => i.compoundValue === this.selectedInstanceName);
|
||
var instanceId = inst && inst.id != null ? inst.id : null;
|
||
if (instanceId == null) return;
|
||
var tmdbId = this.currentMovie && (this.currentMovie.tmdb_id || this.currentMovie.id);
|
||
if (!tmdbId) return;
|
||
|
||
var btn = document.getElementById('requestarr-movie-monitor-btn');
|
||
if (!btn) return;
|
||
var icon = btn.querySelector('i');
|
||
var currentMonitored = icon && icon.classList.contains('fas');
|
||
var newMonitored = !currentMonitored;
|
||
|
||
// Optimistic UI
|
||
if (icon) icon.className = newMonitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
|
||
try {
|
||
var resp = await fetch('./api/movie-hunt/collection/' + tmdbId + '/monitor?instance_id=' + instanceId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ monitored: newMonitored })
|
||
});
|
||
var data = await resp.json();
|
||
if (!resp.ok || data.error) throw new Error(data.error || 'Failed');
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(newMonitored ? 'Monitor on' : 'Monitor off', 'success');
|
||
}
|
||
} catch (e) {
|
||
if (icon) icon.className = currentMonitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to update monitor: ' + e.message, 'error');
|
||
}
|
||
}
|
||
},
|
||
|
||
renderMovieDetail(details, originalMovie) {
|
||
const backdropUrl = details.backdrop_path
|
||
? `https://image.tmdb.org/t/p/original${details.backdrop_path}`
|
||
: (details.poster_path ? `https://image.tmdb.org/t/p/original${details.poster_path}` : '');
|
||
|
||
const posterUrl = details.poster_path
|
||
? `https://image.tmdb.org/t/p/w500${details.poster_path}`
|
||
: './static/images/blackout.jpg';
|
||
|
||
const rating = details.vote_average ? Number(details.vote_average).toFixed(1) : 'N/A';
|
||
const year = details.release_date ? new Date(details.release_date).getFullYear() : 'N/A';
|
||
const runtime = details.runtime ? `${Math.floor(details.runtime / 60)}h ${details.runtime % 60}m` : 'N/A';
|
||
|
||
const genres = details.genres && details.genres.length > 0
|
||
? details.genres.map(g => `<span class="mh-genre-tag">${this.escapeHtml(g.name)}</span>`).join('')
|
||
: '<span class="mh-genre-tag">Unknown</span>';
|
||
|
||
const overview = details.overview || 'No overview available.';
|
||
|
||
// Certification
|
||
let certification = 'Not Rated';
|
||
if (details.release_dates && details.release_dates.results) {
|
||
const usRelease = details.release_dates.results.find(r => r.iso_3166_1 === 'US');
|
||
if (usRelease && usRelease.release_dates && usRelease.release_dates.length > 0) {
|
||
const cert = usRelease.release_dates[0].certification;
|
||
if (cert) certification = cert;
|
||
}
|
||
}
|
||
|
||
// Status button
|
||
const hasInstances = this.movieInstances.length > 0;
|
||
const inLibrary = originalMovie.in_library || false;
|
||
let actionButton = '';
|
||
|
||
if (!hasInstances) {
|
||
actionButton = '<button class="mh-btn" disabled style="background: rgba(55, 65, 81, 0.8); color: #9ca3af; cursor: not-allowed; border: 1px solid rgba(107, 114, 128, 0.5); font-size: 0.95rem; padding: 10px 20px;"><i class="fas fa-server" style="margin-right: 8px; color: #9ca3af;"></i> No Instance Configured \u2014 Add to Get Started</button>';
|
||
} else if (inLibrary) {
|
||
actionButton = '<button class="mh-btn mh-btn-success" disabled><i class="fas fa-check"></i> Already Available</button>';
|
||
} else {
|
||
actionButton = '<button class="mh-btn mh-btn-primary" id="requestarr-detail-request-btn"><i class="fas fa-download"></i> Request Movie</button>';
|
||
}
|
||
|
||
// Director and cast
|
||
let director = 'N/A';
|
||
let mainCast = [];
|
||
|
||
if (details.credits) {
|
||
if (details.credits.crew) {
|
||
const directorObj = details.credits.crew.find(c => c.job === 'Director');
|
||
if (directorObj) director = directorObj.name;
|
||
}
|
||
if (details.credits.cast) {
|
||
mainCast = details.credits.cast.slice(0, 10);
|
||
}
|
||
}
|
||
|
||
// Similar movies
|
||
let similarMovies = [];
|
||
if (details.similar && details.similar.results) {
|
||
similarMovies = details.similar.results.slice(0, 6);
|
||
}
|
||
|
||
// Instance selector - combined Movie Hunt + Radarr
|
||
let instanceSelectorHTML = '';
|
||
if (this.movieInstances.length > 0) {
|
||
instanceSelectorHTML = `
|
||
<div class="mh-hero-instance">
|
||
<i class="fas fa-server"></i>
|
||
<select id="requestarr-detail-instance-select">
|
||
${this.movieInstances.map(instance => {
|
||
const selected = instance.compoundValue === this.selectedInstanceName ? 'selected' : '';
|
||
return `<option value="${this.escapeHtml(instance.compoundValue)}" ${selected}>${this.escapeHtml(instance.label)}</option>`;
|
||
}).join('')}
|
||
</select>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Toolbar: full (Movie Hunt) vs minimal (Radarr)
|
||
var decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
var isMovieHunt = decoded.appType === 'movie_hunt';
|
||
var toolbarHTML = '';
|
||
if (isMovieHunt) {
|
||
toolbarHTML = `
|
||
<div class="mh-toolbar" id="requestarr-detail-toolbar">
|
||
<div class="mh-toolbar-left">
|
||
<button class="mh-tb" id="requestarr-detail-refresh" title="Refresh" style="display:none"><i class="fas fa-redo-alt"></i><span>Refresh</span></button>
|
||
<span id="requestarr-detail-force-container"></span>
|
||
<button class="mh-tb" id="requestarr-detail-search-movie" title="Search Movie" style="display:none"><i class="fas fa-search"></i><span>Search Movie</span></button>
|
||
</div>
|
||
<div class="mh-toolbar-right">
|
||
<button class="mh-tb" id="requestarr-detail-edit" title="Edit" style="display:none"><i class="fas fa-wrench"></i><span>Edit</span></button>
|
||
<button class="mh-tb mh-tb-danger" id="requestarr-detail-delete" title="Delete" style="display:none"><i class="fas fa-trash-alt"></i></button>
|
||
<button class="mh-tb" id="requestarr-detail-hide" title="Hide from discovery" style="display:none"><i class="fas fa-eye-slash"></i></button>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
toolbarHTML = `
|
||
<div class="mh-toolbar" id="requestarr-detail-toolbar">
|
||
<div class="mh-toolbar-left"></div>
|
||
<div class="mh-toolbar-right"></div>
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<!-- Toolbar -->
|
||
${toolbarHTML}
|
||
|
||
<!-- Hero -->
|
||
<div class="mh-hero" style="background-image: url('${backdropUrl}');">
|
||
<div class="mh-hero-grad">
|
||
<div class="mh-hero-layout">
|
||
<div class="mh-hero-poster">
|
||
<img src="${posterUrl}" alt="${this.escapeHtml(details.title)}" onerror="this.src='./static/images/blackout.jpg'">
|
||
</div>
|
||
<div class="mh-hero-info">
|
||
<div class="mh-hero-title-row">
|
||
<h1 class="mh-hero-title">${this.escapeHtml(details.title)}</h1>
|
||
<div class="mh-hero-movie-monitor" id="requestarr-movie-monitor-wrap" style="display:none;">
|
||
<button type="button" class="mh-monitor-btn" id="requestarr-movie-monitor-btn" title="Toggle monitor movie">
|
||
<i class="fas fa-bookmark"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="mh-hero-meta">
|
||
${certification !== 'Not Rated' ? `<span class="mh-cert">${this.escapeHtml(certification)}</span>` : ''}
|
||
<span><i class="fas fa-calendar-alt"></i> ${year}</span>
|
||
<span><i class="fas fa-clock"></i> ${runtime}</span>
|
||
<span class="mh-star"><i class="fas fa-star"></i> ${rating}</span>
|
||
</div>
|
||
<div class="mh-hero-genres">${genres}</div>
|
||
${instanceSelectorHTML}
|
||
<div class="mh-info-bar" id="requestarr-detail-info-bar"${hasInstances ? '' : ' style="display:none"'}>
|
||
<div class="mh-ib mh-ib-path">
|
||
<div class="mh-ib-label">PATH</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-path"><i class="fas fa-spinner fa-spin"></i></div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">STATUS</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-status"><i class="fas fa-spinner fa-spin"></i></div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">QUALITY PROFILE</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-profile">-</div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">SIZE</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-size">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="mh-info-bar mh-info-bar-row2" id="requestarr-info-bar-row2" style="display:none">
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">Resolution</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-resolution">-</div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">Codec / Audio</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-codec">-</div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">Custom Format Score</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-score">-</div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">Min. Availability</div>
|
||
<div class="mh-ib-val" id="requestarr-ib-availability">-</div>
|
||
</div>
|
||
</div>
|
||
<p class="mh-hero-overview">${this.escapeHtml(overview)}</p>
|
||
<div class="mh-hero-actions">
|
||
${actionButton}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Body Sections -->
|
||
<div class="mh-detail-body">
|
||
<!-- Movie Details -->
|
||
<div class="mh-section">
|
||
<h2 class="mh-section-title"><i class="fas fa-info-circle"></i> Movie Details</h2>
|
||
<div class="mh-detail-grid">
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Director</div>
|
||
<div class="mh-grid-value">${this.escapeHtml(director)}</div>
|
||
</div>
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Release Date</div>
|
||
<div class="mh-grid-value">${details.release_date || 'N/A'}</div>
|
||
</div>
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Rating</div>
|
||
<div class="mh-grid-value">${certification}</div>
|
||
</div>
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Budget</div>
|
||
<div class="mh-grid-value">${details.budget ? '$' + (details.budget / 1000000).toFixed(1) + 'M' : 'N/A'}</div>
|
||
</div>
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Revenue</div>
|
||
<div class="mh-grid-value">${details.revenue ? '$' + (details.revenue / 1000000).toFixed(1) + 'M' : 'N/A'}</div>
|
||
</div>
|
||
<div class="mh-grid-item">
|
||
<div class="mh-grid-label">Language</div>
|
||
<div class="mh-grid-value">${details.original_language ? details.original_language.toUpperCase() : 'N/A'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${mainCast.length > 0 ? `
|
||
<!-- Cast -->
|
||
<div class="mh-section">
|
||
<h2 class="mh-section-title"><i class="fas fa-users"></i> Cast</h2>
|
||
<div class="mh-cast-row">
|
||
${mainCast.map(actor => this.renderCastCard(actor)).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${similarMovies.length > 0 ? `
|
||
<!-- Similar Movies -->
|
||
<div class="mh-section">
|
||
<h2 class="mh-section-title"><i class="fas fa-film"></i> Similar Movies</h2>
|
||
<div class="mh-similar-row">
|
||
${similarMovies.map(movie => this.renderSimilarCard(movie)).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
renderCastCard(actor) {
|
||
const photoUrl = actor.profile_path
|
||
? `https://image.tmdb.org/t/p/w185${actor.profile_path}`
|
||
: './static/images/blackout.jpg';
|
||
|
||
return `
|
||
<div class="mh-cast-card">
|
||
<div class="mh-cast-photo">
|
||
<img src="${photoUrl}" alt="${this.escapeHtml(actor.name)}" onerror="this.src='./static/images/blackout.jpg'">
|
||
</div>
|
||
<div class="mh-cast-name">${this.escapeHtml(actor.name)}</div>
|
||
<div class="mh-cast-char">${this.escapeHtml(actor.character || 'Unknown')}</div>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
renderSimilarCard(movie) {
|
||
const posterUrl = movie.poster_path
|
||
? `https://image.tmdb.org/t/p/w185${movie.poster_path}`
|
||
: './static/images/blackout.jpg';
|
||
|
||
return `
|
||
<div class="mh-similar-card media-card" data-tmdb-id="${movie.id}">
|
||
<div class="media-card-poster">
|
||
<img src="${posterUrl}" alt="${this.escapeHtml(movie.title)}" onerror="this.src='./static/images/blackout.jpg'">
|
||
<span class="media-type-badge">Movie</span>
|
||
<div class="media-card-overlay">
|
||
<div class="media-card-overlay-title">${this.escapeHtml(movie.title)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="media-card-info">
|
||
<div class="media-card-title">${this.escapeHtml(movie.title)}</div>
|
||
<div class="media-card-meta">
|
||
<span class="media-card-year">${movie.release_date ? new Date(movie.release_date).getFullYear() : 'N/A'}</span>
|
||
<span class="media-card-rating"><i class="fas fa-star"></i> ${movie.vote_average ? Number(movie.vote_average).toFixed(1) : 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
setupDetailInteractions() {
|
||
var self = this;
|
||
this.attachToolbarHandlers();
|
||
|
||
// Instance selector: stay on Requestarr; toolbar and data update by instance type (Movie Hunt vs Radarr)
|
||
const instanceSelect = document.getElementById('requestarr-detail-instance-select');
|
||
if (instanceSelect) {
|
||
instanceSelect.addEventListener('change', async () => {
|
||
const newValue = instanceSelect.value;
|
||
this.selectedInstanceName = newValue;
|
||
console.log('[RequestarrDetail] Instance changed to:', this.selectedInstanceName);
|
||
var isMovieHunt = _decodeInstanceValue(newValue).appType === 'movie_hunt';
|
||
this.replaceAndAttachToolbar(isMovieHunt);
|
||
this.updateDetailInfoBar();
|
||
});
|
||
this.updateDetailInfoBar();
|
||
}
|
||
|
||
// Request button → Requestarr modal (unified for all instance types)
|
||
const requestBtn = document.getElementById('requestarr-detail-request-btn');
|
||
if (requestBtn && this.currentMovie) {
|
||
requestBtn.addEventListener('click', () => {
|
||
if (window.RequestarrDiscover && window.RequestarrDiscover.modal) {
|
||
window.RequestarrDiscover.modal.openModal(
|
||
this.currentMovie.tmdb_id,
|
||
'movie',
|
||
this.selectedInstanceName
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check for existing request status and update button accordingly
|
||
this._checkRequestStatus();
|
||
|
||
// ── Auto-refresh after request/edit/delete via shared event system ──
|
||
if (window.MediaUtils) {
|
||
window.MediaUtils.teardownDetailRefreshListeners(this._refreshHandle);
|
||
this._refreshHandle = window.MediaUtils.setupDetailRefreshListeners({
|
||
getTmdbId: function() { return self.currentMovie && (self.currentMovie.tmdb_id || self.currentMovie.id); },
|
||
refreshCallback: function() { self.updateDetailInfoBar(); },
|
||
label: 'RequestarrDetail'
|
||
});
|
||
}
|
||
|
||
// Similar movie cards
|
||
const similarCards = document.querySelectorAll('.mh-similar-card.media-card');
|
||
similarCards.forEach(card => {
|
||
card.addEventListener('click', async () => {
|
||
const tmdbId = card.getAttribute('data-tmdb-id');
|
||
if (tmdbId) {
|
||
try {
|
||
const details = await this.fetchMovieDetails(tmdbId);
|
||
if (details) {
|
||
const movieData = {
|
||
tmdb_id: details.id,
|
||
id: details.id,
|
||
title: details.title,
|
||
year: details.release_date ? new Date(details.release_date).getFullYear() : null,
|
||
poster_path: details.poster_path,
|
||
backdrop_path: details.backdrop_path,
|
||
overview: details.overview,
|
||
vote_average: details.vote_average,
|
||
in_library: false
|
||
};
|
||
this.openDetail(movieData, this.options || {}, false);
|
||
}
|
||
} catch (error) {
|
||
console.error('[RequestarrDetail] Error opening similar movie:', error);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ESC key — store handler so closeDetail() can remove it (prevents stacking)
|
||
if (this._escHandler) {
|
||
document.removeEventListener('keydown', this._escHandler);
|
||
}
|
||
this._escHandler = (e) => {
|
||
if (e.key === 'Escape') {
|
||
this.closeDetail();
|
||
}
|
||
};
|
||
document.addEventListener('keydown', this._escHandler);
|
||
},
|
||
|
||
setupErrorBackButton() {
|
||
const errorBackBtn = document.getElementById('requestarr-detail-back-error');
|
||
if (errorBackBtn) {
|
||
errorBackBtn.addEventListener('click', () => this.closeDetail());
|
||
}
|
||
},
|
||
|
||
getLoadingHTML() {
|
||
return `
|
||
<div class="mh-toolbar">
|
||
<div class="movie-detail-loading">
|
||
<i class="fas fa-spinner fa-spin"></i>
|
||
<p>Loading movie details...</p>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
getErrorHTML(message) {
|
||
return `
|
||
<div class="movie-detail-loading">
|
||
<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i>
|
||
<p style="color: #ef4444;">${this.escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => window.RequestarrDetail.init());
|
||
} else {
|
||
window.RequestarrDetail.init();
|
||
}
|
||
})();
|
||
|
||
|
||
/* === modules/features/requestarr/requestarr-tv-detail.js === */
|
||
/**
|
||
* Requestarr TV Detail Page – TV Hunt (full page) + Sonarr (limited top bar)
|
||
* Mirrors requestarr-detail.js behavior for movies
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
function _encodeInstanceValue(appType, name) {
|
||
return window.MediaUtils ? window.MediaUtils.encodeInstanceValue(appType, name) : (appType + ':' + name);
|
||
}
|
||
function _decodeInstanceValue(value) {
|
||
return window.MediaUtils ? window.MediaUtils.decodeInstanceValue(value, 'sonarr') : { appType: 'sonarr', name: (value || '').split(':')[1] || '' };
|
||
}
|
||
|
||
window.RequestarrTVDetail = {
|
||
currentSeries: null,
|
||
tvInstances: [], // TV Hunt + Sonarr
|
||
selectedInstanceName: null,
|
||
seriesStatus: null, // { exists, seasons: [{ season_number, episodes: [{ episode_number, status }] }] }
|
||
|
||
init() {
|
||
window.addEventListener('popstate', (e) => {
|
||
if (e.state && e.state.requestarrTVDetail) {
|
||
this.openDetail(e.state.requestarrTVDetail, e.state.options || {}, true);
|
||
} else {
|
||
this.closeDetail(true);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('hashchange', () => {
|
||
const hash = window.location.hash || '';
|
||
const m = hash.match(/^#requestarr-tv\/(\d+)$/);
|
||
if (m) {
|
||
const tmdbId = parseInt(m[1], 10);
|
||
this.openDetail({ id: tmdbId, tmdb_id: tmdbId }, {}, true);
|
||
} else {
|
||
this.closeDetail(true);
|
||
}
|
||
});
|
||
|
||
// Restore detail on refresh when URL has #requestarr-tv/ID
|
||
const hash = window.location.hash || '';
|
||
const m = hash.match(/^#requestarr-tv\/(\d+)$/);
|
||
if (m) {
|
||
const tmdbId = parseInt(m[1], 10);
|
||
this.openDetail({ id: tmdbId, tmdb_id: tmdbId }, {}, true);
|
||
}
|
||
},
|
||
|
||
async openDetail(series, options = {}, fromHistory = false) {
|
||
if (!series) return;
|
||
|
||
this.currentSeries = series;
|
||
this.options = options || {};
|
||
const tmdbId = series.tmdb_id || series.id;
|
||
|
||
if (this.tvInstances.length === 0) {
|
||
await this.loadTVInstances();
|
||
}
|
||
|
||
let detailView = document.getElementById('requestarr-tv-detail-view');
|
||
if (!detailView) {
|
||
detailView = document.createElement('div');
|
||
detailView.id = 'requestarr-tv-detail-view';
|
||
detailView.className = 'movie-detail-view';
|
||
document.body.appendChild(detailView);
|
||
}
|
||
|
||
detailView.innerHTML = this.getLoadingHTML();
|
||
detailView.classList.add('active');
|
||
|
||
if (!fromHistory) {
|
||
const url = `${window.location.pathname}${window.location.search}#requestarr-tv/${tmdbId}`;
|
||
history.pushState({ requestarrTVDetail: series, options: this.options }, series.title || series.name, url);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
const backBtn = document.getElementById('requestarr-tv-detail-back-loading');
|
||
if (backBtn) backBtn.addEventListener('click', () => this.closeDetail());
|
||
}, 0);
|
||
|
||
try {
|
||
const details = await this.fetchSeriesDetails(tmdbId);
|
||
if (details) {
|
||
this.currentSeries = details; // Update to full TMDB details
|
||
detailView.innerHTML = this.renderTVDetail(details, series);
|
||
await this.setupDetailInteractions();
|
||
} else {
|
||
detailView.innerHTML = this.getErrorHTML('Failed to load series details');
|
||
this.setupErrorBackButton();
|
||
}
|
||
} catch (error) {
|
||
console.error('[RequestarrTVDetail] Error:', error);
|
||
detailView.innerHTML = this.getErrorHTML('Failed to load series details');
|
||
this.setupErrorBackButton();
|
||
}
|
||
},
|
||
|
||
closeDetail(fromHistory = false) {
|
||
const detailView = document.getElementById('requestarr-tv-detail-view');
|
||
if (detailView) detailView.classList.remove('active');
|
||
|
||
if (this._escHandler) {
|
||
document.removeEventListener('keydown', this._escHandler);
|
||
this._escHandler = null;
|
||
}
|
||
|
||
if (!fromHistory && /^#requestarr-tv\//.test(window.location.hash || '')) {
|
||
history.back();
|
||
}
|
||
},
|
||
|
||
async fetchSeriesDetails(tmdbId) {
|
||
try {
|
||
const response = await fetch(`./api/tv-hunt/series/${tmdbId}`);
|
||
if (!response.ok) return null;
|
||
return await response.json();
|
||
} catch (e) {
|
||
console.error('[RequestarrTVDetail] Fetch error:', e);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
async loadTVInstances() {
|
||
try {
|
||
const [tvHuntRes, sonarrRes] = await Promise.all([
|
||
fetch('./api/requestarr/instances/tv_hunt'),
|
||
fetch('./api/requestarr/instances/sonarr')
|
||
]);
|
||
const tvHuntData = await tvHuntRes.json();
|
||
const sonarrData = await sonarrRes.json();
|
||
|
||
const combined = [];
|
||
if (tvHuntData.instances) {
|
||
tvHuntData.instances.forEach(inst => {
|
||
combined.push({
|
||
name: inst.name,
|
||
id: inst.id,
|
||
appType: 'tv_hunt',
|
||
compoundValue: _encodeInstanceValue('tv_hunt', inst.name),
|
||
label: 'TV Hunt – ' + inst.name
|
||
});
|
||
});
|
||
}
|
||
if (sonarrData.instances) {
|
||
sonarrData.instances.forEach(inst => {
|
||
combined.push({
|
||
name: inst.name,
|
||
appType: 'sonarr',
|
||
compoundValue: _encodeInstanceValue('sonarr', inst.name),
|
||
label: 'Sonarr – ' + inst.name
|
||
});
|
||
});
|
||
}
|
||
|
||
this.tvInstances = combined;
|
||
|
||
if (combined.length > 0) {
|
||
this.selectedInstanceName = this.options.suggestedInstance || combined[0].compoundValue;
|
||
} else {
|
||
this.selectedInstanceName = null;
|
||
}
|
||
} catch (e) {
|
||
console.error('[RequestarrTVDetail] Load instances error:', e);
|
||
this.tvInstances = [];
|
||
this.selectedInstanceName = null;
|
||
}
|
||
},
|
||
|
||
async checkSeriesStatus(tmdbId, instanceValue) {
|
||
if (!instanceValue) return { exists: false, previously_requested: false };
|
||
try {
|
||
const decoded = _decodeInstanceValue(instanceValue);
|
||
const appType = decoded.appType || 'sonarr';
|
||
const response = await fetch(`./api/requestarr/series-status?tmdb_id=${tmdbId}&instance=${encodeURIComponent(decoded.name)}&app_type=${encodeURIComponent(appType)}&_=${Date.now()}`);
|
||
return await response.json();
|
||
} catch (e) {
|
||
return { exists: false, previously_requested: false };
|
||
}
|
||
},
|
||
|
||
escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
},
|
||
|
||
/**
|
||
* Check if there's an existing request for this TV show and update the action button.
|
||
*/
|
||
async _checkRequestStatus() {
|
||
if (!this.currentSeries) return;
|
||
const tmdbId = this.currentSeries.tmdb_id || this.currentSeries.id;
|
||
if (!tmdbId) return;
|
||
try {
|
||
const resp = await fetch(`./api/requestarr/requests/check/tv/${tmdbId}`, { cache: 'no-store' });
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (data.exists && data.request) {
|
||
const btn = document.getElementById('requestarr-tv-detail-request-btn');
|
||
if (!btn) return;
|
||
const status = data.request.status;
|
||
if (status === 'pending') {
|
||
btn.innerHTML = '<i class="fas fa-clock"></i> Request Pending';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-warning');
|
||
btn.disabled = true;
|
||
} else if (status === 'approved') {
|
||
btn.innerHTML = '<i class="fas fa-check-circle"></i> Request Approved';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-success');
|
||
btn.disabled = true;
|
||
} else if (status === 'denied') {
|
||
btn.innerHTML = '<i class="fas fa-times-circle"></i> Denied — Re-request';
|
||
btn.classList.remove('mh-btn-primary');
|
||
btn.classList.add('mh-btn-denied');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.debug('[RequestarrTVDetail] Request status check skipped:', e);
|
||
}
|
||
},
|
||
|
||
renderTVDetail(details, originalSeries) {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
const isTVHunt = decoded.appType === 'tv_hunt';
|
||
const hasTVHuntInstances = this.tvInstances.some(inst => {
|
||
const d = _decodeInstanceValue(inst.compoundValue);
|
||
return d.appType === 'tv_hunt';
|
||
});
|
||
|
||
const backdropUrl = details.backdrop_path
|
||
? `https://image.tmdb.org/t/p/original${details.backdrop_path}`
|
||
: (details.poster_path ? `https://image.tmdb.org/t/p/original${details.poster_path}` : '');
|
||
|
||
const posterUrl = details.poster_path
|
||
? `https://image.tmdb.org/t/p/w500${details.poster_path}`
|
||
: './static/images/blackout.jpg';
|
||
|
||
const title = details.name || details.title || 'Unknown';
|
||
const year = details.first_air_date ? new Date(details.first_air_date).getFullYear() : 'N/A';
|
||
const rating = details.vote_average ? Number(details.vote_average).toFixed(1) : 'N/A';
|
||
const genres = details.genres && details.genres.length > 0
|
||
? details.genres.map(g => `<span class="mh-genre-tag">${this.escapeHtml(g.name)}</span>`).join('')
|
||
: '<span class="mh-genre-tag">Unknown</span>';
|
||
const overview = details.overview || 'No overview available.';
|
||
|
||
const hasInstances = this.tvInstances.length > 0;
|
||
const inLibrary = originalSeries.in_library || false;
|
||
let actionButton = '';
|
||
if (!hasInstances) {
|
||
actionButton = '<button class="mh-btn" disabled style="background: rgba(55, 65, 81, 0.8); color: #9ca3af; cursor: not-allowed;"><i class="fas fa-server" style="margin-right: 8px;"></i> No Instance Configured</button>';
|
||
} else if (inLibrary) {
|
||
actionButton = '<button class="mh-btn mh-btn-success" disabled><i class="fas fa-check"></i> Already Available</button>';
|
||
} else {
|
||
actionButton = '<button class="mh-btn mh-btn-primary" id="requestarr-tv-detail-request-btn"><i class="fas fa-download"></i> Request Series</button>';
|
||
}
|
||
|
||
let instanceSelectorHTML = '';
|
||
if (this.tvInstances.length > 0) {
|
||
instanceSelectorHTML = `
|
||
<div class="mh-hero-instance">
|
||
<i class="fas fa-server"></i>
|
||
<select id="requestarr-tv-detail-instance-select">
|
||
${this.tvInstances.map(inst => {
|
||
const selected = inst.compoundValue === this.selectedInstanceName ? 'selected' : '';
|
||
return `<option value="${this.escapeHtml(inst.compoundValue)}" ${selected}>${this.escapeHtml(inst.label)}</option>`;
|
||
}).join('')}
|
||
</select>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const toolbarHTML = `
|
||
<div class="mh-toolbar" id="requestarr-tv-detail-toolbar">
|
||
<div class="mh-toolbar-left">
|
||
${isTVHunt ? '<button class="mh-tb" id="requestarr-tv-detail-refresh" title="Refresh" style="display:none"><i class="fas fa-redo-alt"></i><span>Refresh</span></button>' : ''}
|
||
${isTVHunt ? '<button class="mh-tb" id="requestarr-tv-search-monitored" style="display:none"><i class="fas fa-search"></i> <span>Search Monitored</span></button>' : ''}
|
||
</div>
|
||
<div class="mh-toolbar-right">
|
||
${isTVHunt ? '<button class="mh-tb" id="requestarr-tv-detail-edit" title="Edit" style="display:none"><i class="fas fa-wrench"></i><span>Edit</span></button>' : ''}
|
||
${isTVHunt ? '<button class="mh-tb mh-tb-danger" id="requestarr-tv-detail-delete" title="Delete" style="display:none"><i class="fas fa-trash-alt"></i></button>' : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
const seasonsHTML = this.renderSeasonsSection(details);
|
||
|
||
return `
|
||
${toolbarHTML}
|
||
<div class="mh-hero" style="background-image: url('${backdropUrl}');">
|
||
<div class="mh-hero-grad">
|
||
<div class="mh-hero-layout">
|
||
<div class="mh-hero-poster">
|
||
<img src="${posterUrl}" alt="${this.escapeHtml(title)}" onerror="this.src='./static/images/blackout.jpg'">
|
||
</div>
|
||
<div class="mh-hero-info">
|
||
<div class="mh-hero-title-row">
|
||
<h1 class="mh-hero-title">${this.escapeHtml(title)}</h1>
|
||
${hasTVHuntInstances ? '<div class="mh-hero-series-monitor" id="requestarr-tv-series-monitor-wrap" style="display:none;"><button type="button" class="mh-monitor-btn" id="requestarr-tv-series-monitor-btn" title="Toggle monitor series"><i class="fas fa-bookmark"></i></button></div>' : ''}
|
||
</div>
|
||
<div class="mh-hero-meta">
|
||
<span><i class="fas fa-calendar-alt"></i> ${year}</span>
|
||
<span class="mh-star"><i class="fas fa-star"></i> ${rating}</span>
|
||
</div>
|
||
<div class="mh-hero-genres">${genres}</div>
|
||
${instanceSelectorHTML}
|
||
<div class="mh-info-bar" id="requestarr-tv-detail-info-bar"${hasInstances ? '' : ' style="display:none"'}>
|
||
<div class="mh-ib mh-ib-path">
|
||
<div class="mh-ib-label">PATH</div>
|
||
<div class="mh-ib-val" id="requestarr-tv-ib-path"><i class="fas fa-spinner fa-spin"></i></div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">STATUS</div>
|
||
<div class="mh-ib-val" id="requestarr-tv-ib-status"><i class="fas fa-spinner fa-spin"></i></div>
|
||
</div>
|
||
<div class="mh-ib">
|
||
<div class="mh-ib-label">EPISODES</div>
|
||
<div class="mh-ib-val" id="requestarr-tv-ib-episodes"><i class="fas fa-spinner fa-spin"></i></div>
|
||
</div>
|
||
</div>
|
||
<p class="mh-hero-overview">${this.escapeHtml(overview)}</p>
|
||
<div class="mh-hero-actions" id="requestarr-tv-detail-actions" style="${isTVHunt ? 'display:none' : ''}">${actionButton}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mh-detail-body">
|
||
${seasonsHTML}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
renderSeasonsSection(details) {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
const isTVHunt = decoded.appType === 'tv_hunt';
|
||
const seasonIcon = 'fa-download';
|
||
const seasons = details.seasons || [];
|
||
// Sort newest first (by season number; specials last)
|
||
const sorted = [...seasons].sort((a, b) => {
|
||
if (a.season_number === 0) return 1;
|
||
if (b.season_number === 0) return -1;
|
||
return b.season_number - a.season_number;
|
||
});
|
||
|
||
if (sorted.length === 0) return '';
|
||
|
||
let html = '<div class="mh-section"><h2 class="mh-section-title"><i class="fas fa-layer-group"></i> Seasons</h2><div class="requestarr-tv-seasons-list">';
|
||
sorted.forEach(season => {
|
||
const name = season.name || ('Season ' + season.season_number);
|
||
const total = season.episode_count != null ? season.episode_count : 0;
|
||
const monitorBtn = isTVHunt ? '<button type="button" class="mh-monitor-btn mh-monitor-season mh-tv-hunt-only" data-season="' + season.season_number + '" title="Toggle monitor season"><i class="fas fa-bookmark"></i></button>' : '';
|
||
const requestSeasonBtn = '<div class="season-actions"><button class="season-action-btn request-season-btn request-season-btn-unknown" title="Request entire season" data-season="' + season.season_number + '" data-total="' + total + '"><i class="fas ' + seasonIcon + '"></i></button></div>';
|
||
const badgeSpan = '<span class="season-count-badge season-count-badge-unknown" data-season="' + season.season_number + '" data-total="' + total + '">– / ' + total + '</span>';
|
||
html += `
|
||
<div class="requestarr-tv-season-item" data-season="${season.season_number}" data-tmdb-id="${details.id}">
|
||
<span class="season-chevron"><i class="fas fa-chevron-right"></i></span>
|
||
${monitorBtn}
|
||
<span class="season-name">${this.escapeHtml(name)}</span>
|
||
${badgeSpan}
|
||
${requestSeasonBtn}
|
||
</div>
|
||
`;
|
||
});
|
||
html += '</div></div>';
|
||
return html;
|
||
},
|
||
|
||
updateSeasonCountBadges() {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
const isTVHunt = decoded.appType === 'tv_hunt';
|
||
const seasonIcon = 'fa-download';
|
||
document.querySelectorAll('.season-count-badge').forEach(el => {
|
||
const seasonItem = el.closest('.requestarr-tv-season-item');
|
||
const seasonNum = parseInt(el.dataset.season, 10);
|
||
const total = parseInt(el.dataset.total, 10) || 0;
|
||
const epMap = this.buildEpisodeStatusMap(seasonNum);
|
||
const available = Object.keys(epMap).length;
|
||
el.textContent = `${available} / ${total}`;
|
||
el.classList.remove('season-count-badge-unknown', 'season-count-badge-empty', 'season-count-badge-partial', 'season-count-badge-complete');
|
||
if (total === 0) {
|
||
el.classList.add('season-count-badge-unknown');
|
||
} else if (available === 0) {
|
||
el.classList.add('season-count-badge-empty');
|
||
} else if (available < total) {
|
||
el.classList.add('season-count-badge-partial');
|
||
} else {
|
||
el.classList.add('season-count-badge-complete');
|
||
}
|
||
// Update season monitor bookmark (TV Hunt only)
|
||
if (isTVHunt && seasonItem) {
|
||
const monBtn = seasonItem.querySelector('.mh-monitor-season');
|
||
if (monBtn) {
|
||
const seasonData = this.seriesStatus && this.seriesStatus.seasons
|
||
? this.seriesStatus.seasons.find(s => (s.season_number ?? s.seasonNumber) === seasonNum)
|
||
: null;
|
||
const mon = seasonData ? !!seasonData.monitored : false;
|
||
monBtn.querySelector('i').className = mon ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
}
|
||
}
|
||
// Update Request Season button: icon, color state, disabled when full
|
||
const btn = seasonItem && seasonItem.querySelector('.request-season-btn');
|
||
if (btn) {
|
||
btn.classList.remove('request-season-btn-unknown', 'request-season-btn-empty', 'request-season-btn-partial', 'request-season-btn-complete', 'request-season-btn-upgrade');
|
||
if (total === 0) {
|
||
btn.querySelector('i').className = 'fas fa-download';
|
||
btn.classList.add('request-season-btn-unknown');
|
||
btn.disabled = true;
|
||
} else if (available >= total) {
|
||
btn.querySelector('i').className = 'fas fa-arrow-up';
|
||
btn.classList.add('request-season-btn-upgrade');
|
||
btn.disabled = false;
|
||
btn.title = "Upgrade entire season";
|
||
} else {
|
||
btn.querySelector('i').className = 'fas fa-download';
|
||
if (available > 0) {
|
||
btn.classList.add('request-season-btn-partial');
|
||
} else {
|
||
btn.classList.add('request-season-btn-empty');
|
||
}
|
||
btn.disabled = false;
|
||
btn.title = "Request missing episodes";
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
updateEpisodeMonitorIcons() {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'tv_hunt') return;
|
||
document.querySelectorAll('.requestarr-tv-season-episodes.expanded').forEach(episodesEl => {
|
||
const seasonItem = episodesEl.previousElementSibling;
|
||
if (!seasonItem || !seasonItem.classList.contains('requestarr-tv-season-item')) return;
|
||
const seasonNum = parseInt(seasonItem.dataset.season, 10);
|
||
const monMap = this.buildEpisodeMonitoredMap(seasonNum);
|
||
episodesEl.querySelectorAll('.mh-monitor-episode').forEach(btn => {
|
||
const epNum = parseInt(btn.dataset.episode, 10);
|
||
const monitored = !!monMap[epNum];
|
||
const icon = btn.querySelector('i');
|
||
if (icon) icon.className = monitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
});
|
||
});
|
||
},
|
||
|
||
async setupDetailInteractions() {
|
||
const self = this;
|
||
const backBtn = document.getElementById('requestarr-tv-detail-back');
|
||
if (backBtn) backBtn.addEventListener('click', () => this.closeDetail());
|
||
|
||
const instanceSelect = document.getElementById('requestarr-tv-detail-instance-select');
|
||
if (instanceSelect) {
|
||
instanceSelect.addEventListener('change', async () => {
|
||
this.selectedInstanceName = instanceSelect.value;
|
||
this.collapseExpandedSeasons();
|
||
await this.updateDetailInfoBar();
|
||
});
|
||
}
|
||
|
||
const requestBtn = document.getElementById('requestarr-tv-detail-request-btn');
|
||
if (requestBtn && this.currentSeries) {
|
||
requestBtn.addEventListener('click', () => {
|
||
if (window.RequestarrDiscover && window.RequestarrDiscover.modal) {
|
||
window.RequestarrDiscover.modal.openModal(
|
||
this.currentSeries.tmdb_id || this.currentSeries.id,
|
||
'tv',
|
||
this.selectedInstanceName
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check for existing request status and update button accordingly
|
||
this._checkRequestStatus();
|
||
|
||
const seriesMonitorBtn = document.getElementById('requestarr-tv-series-monitor-btn');
|
||
if (seriesMonitorBtn) {
|
||
const tmdbId = this.currentSeries.tmdb_id || this.currentSeries.id;
|
||
seriesMonitorBtn.onclick = async () => {
|
||
await this.toggleMonitor(tmdbId, null, null);
|
||
};
|
||
}
|
||
|
||
const searchMonitoredBtn = document.getElementById('requestarr-tv-search-monitored');
|
||
if (searchMonitoredBtn) {
|
||
searchMonitoredBtn.onclick = async () => {
|
||
await this.searchMonitoredEpisodes();
|
||
};
|
||
}
|
||
|
||
const refreshBtn = document.getElementById('requestarr-tv-detail-refresh');
|
||
if (refreshBtn) {
|
||
refreshBtn.addEventListener('click', async () => {
|
||
if (refreshBtn.disabled) return;
|
||
refreshBtn.disabled = true;
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Refresh scan initiated.', 'success');
|
||
}
|
||
try {
|
||
await this.updateDetailInfoBar();
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Refresh failed.', 'error');
|
||
}
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
const editBtn = document.getElementById('requestarr-tv-detail-edit');
|
||
if (editBtn) editBtn.addEventListener('click', () => this.openEditModalForTVHunt());
|
||
|
||
const deleteBtn = document.getElementById('requestarr-tv-detail-delete');
|
||
if (deleteBtn) deleteBtn.addEventListener('click', () => this.openDeleteModalForTVHunt());
|
||
|
||
// Must load series status first so buildEpisodeStatusMap has Sonarr/TV Hunt data for episode status and resolution
|
||
await this.updateDetailInfoBar();
|
||
|
||
const seasonItems = document.querySelectorAll('.requestarr-tv-season-item');
|
||
seasonItems.forEach(item => {
|
||
const requestSeasonBtn = item.querySelector('.request-season-btn');
|
||
if (requestSeasonBtn) {
|
||
requestSeasonBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.requestSeason(item.dataset.tmdbId, parseInt(item.dataset.season, 10));
|
||
});
|
||
}
|
||
const monitorSeasonBtn = item.querySelector('.mh-monitor-season');
|
||
if (monitorSeasonBtn) {
|
||
monitorSeasonBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.toggleMonitor(item.dataset.tmdbId, parseInt(item.dataset.season, 10), null);
|
||
});
|
||
}
|
||
|
||
item.addEventListener('click', (e) => {
|
||
if (e.target.closest('.season-actions')) return;
|
||
const seasonNum = parseInt(item.dataset.season, 10);
|
||
const tmdbId = item.dataset.tmdbId;
|
||
const body = item.nextElementSibling;
|
||
if (body && body.classList.contains('requestarr-tv-season-episodes')) {
|
||
item.classList.toggle('expanded');
|
||
body.classList.toggle('expanded');
|
||
return;
|
||
}
|
||
const episodesEl = document.createElement('div');
|
||
episodesEl.className = 'requestarr-tv-season-episodes';
|
||
episodesEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading episodes...';
|
||
item.after(episodesEl);
|
||
item.classList.add('expanded');
|
||
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
const isTVHunt = decoded.appType === 'tv_hunt';
|
||
|
||
const runExpand = async () => {
|
||
const renderEpisodes = (eps) => {
|
||
const epStatusMap = this.buildEpisodeStatusMap(seasonNum);
|
||
const epMonitoredMap = isTVHunt ? this.buildEpisodeMonitoredMap(seasonNum) : {};
|
||
const sorted = [...eps].sort((a, b) => (b.episode_number ?? b.episodeNumber ?? 0) - (a.episode_number ?? a.episodeNumber ?? 0));
|
||
const monitorCol = isTVHunt ? '<th></th>' : '';
|
||
let tbl = '<table class="episode-table"><thead><tr>' + monitorCol + '<th>#</th><th>Title</th><th>Air Date</th><th>Availability</th><th></th></tr></thead><tbody>';
|
||
sorted.forEach(ep => {
|
||
const epNum = ep.episode_number ?? ep.episodeNumber;
|
||
const title = ep.title || ep.name || '';
|
||
const ad = ep.air_date || ep.airDate || '';
|
||
const epInfo = epStatusMap[epNum];
|
||
const available = !!epInfo;
|
||
const airDateObj = ad ? new Date(ad) : null;
|
||
const isFutureAirDate = airDateObj && !isNaN(airDateObj.getTime()) && airDateObj > new Date();
|
||
const quality = (epInfo && typeof epInfo === 'object' && epInfo.quality) ? epInfo.quality : null;
|
||
let statusBadge;
|
||
if (available) {
|
||
statusBadge = '<span class="mh-ep-status mh-ep-status-ok">' + (quality ? this.escapeHtml(quality) : '<i class="fas fa-check-circle"></i> In Library') + '</span>';
|
||
} else if (isFutureAirDate) {
|
||
statusBadge = '<span class="mh-ep-status mh-ep-status-notreleased">Not Released</span>';
|
||
} else {
|
||
statusBadge = '<span class="mh-ep-status mh-ep-status-warn">Missing</span>';
|
||
}
|
||
const epReqClass = isFutureAirDate ? 'ep-request-btn ep-request-notreleased' : 'ep-request-btn ep-request-missing';
|
||
const requestBtn = !available ? `<button class="${epReqClass}" data-season="${seasonNum}" data-episode="${epNum}" title="Request episode"><i class="fas fa-download"></i></button>` : `<button class="ep-upgrade-btn" data-season="${seasonNum}" data-episode="${epNum}" title="Upgrade episode"><i class="fas fa-arrow-up"></i></button>`;
|
||
const monCell = isTVHunt ? '<td><button type="button" class="mh-monitor-btn mh-monitor-episode" data-season="' + seasonNum + '" data-episode="' + epNum + '" title="Toggle monitor"><i class="' + (epMonitoredMap[epNum] ? 'fas' : 'far') + ' fa-bookmark"></i></button></td>' : '';
|
||
tbl += `<tr>${monCell}<td>${epNum || ''}</td><td>${this.escapeHtml(title)}</td><td>${ad}</td><td>${statusBadge}</td><td>${requestBtn}</td></tr>`;
|
||
});
|
||
tbl += '</tbody></table>';
|
||
episodesEl.innerHTML = tbl;
|
||
episodesEl.classList.add('expanded');
|
||
episodesEl.querySelectorAll('.ep-request-btn, .ep-upgrade-btn').forEach(btn => {
|
||
btn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
this.requestEpisode(item.dataset.tmdbId, parseInt(btn.dataset.season, 10), parseInt(btn.dataset.episode, 10));
|
||
});
|
||
});
|
||
if (isTVHunt) {
|
||
episodesEl.querySelectorAll('.mh-monitor-episode').forEach(btn => {
|
||
btn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
this.toggleMonitor(item.dataset.tmdbId, parseInt(btn.dataset.season, 10), parseInt(btn.dataset.episode, 10));
|
||
});
|
||
});
|
||
}
|
||
};
|
||
|
||
// Ensure Sonarr status is loaded before rendering (needed for episode status/resolution)
|
||
if (!isTVHunt && (!this.seriesStatus || !this.seriesStatus.seasons)) {
|
||
await this.updateDetailInfoBar();
|
||
}
|
||
|
||
// Always use TMDB (tv-hunt API) for episode list; status comes from Sonarr or TV Hunt via buildEpisodeStatusMap
|
||
try {
|
||
const seasonRes = await fetch(`./api/tv-hunt/series/${tmdbId}/season/${seasonNum}`);
|
||
const seasonData = await seasonRes.json();
|
||
const eps = seasonData.episodes || [];
|
||
renderEpisodes(eps);
|
||
} catch {
|
||
episodesEl.innerHTML = '<span style="color:#f87171;">Failed to load episodes</span>';
|
||
}
|
||
};
|
||
runExpand();
|
||
});
|
||
});
|
||
|
||
if (this._escHandler) {
|
||
document.removeEventListener('keydown', this._escHandler);
|
||
}
|
||
this._escHandler = (e) => {
|
||
if (e.key === 'Escape') this.closeDetail();
|
||
};
|
||
document.addEventListener('keydown', this._escHandler);
|
||
},
|
||
|
||
async searchMonitoredEpisodes() {
|
||
if (!this.currentSeries) {
|
||
console.error('[RequestarrTVDetail] No currentSeries for searchMonitored');
|
||
return;
|
||
}
|
||
const tmdbId = this.currentSeries.id || this.currentSeries.tmdb_id;
|
||
const title = this.currentSeries.name || this.currentSeries.title;
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
|
||
console.log('[RequestarrTVDetail] searchMonitored:', { title, tmdbId, instanceId });
|
||
|
||
if (!instanceId) {
|
||
if (window.huntarrUI?.showNotification) window.huntarrUI.showNotification('Select a TV Hunt instance.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!title) {
|
||
if (window.huntarrUI?.showNotification) window.huntarrUI.showNotification('Series title not found.', 'error');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('requestarr-tv-search-monitored');
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.dataset.oldHtml = btn.innerHTML;
|
||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <span>Searching...</span>';
|
||
}
|
||
|
||
try {
|
||
const r = await fetch(`./api/tv-hunt/request?instance_id=${instanceId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
series_title: title,
|
||
tmdb_id: parseInt(tmdbId, 10),
|
||
search_type: 'monitored',
|
||
instance_id: instanceId,
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.message || (data.success ? 'Search completed' : 'Search failed'), data.success ? 'success' : 'error');
|
||
}
|
||
} catch (e) {
|
||
console.error('[RequestarrTVDetail] searchMonitored error:', e);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Search failed.', 'error');
|
||
}
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = btn.dataset.oldHtml || '<i class="fas fa-search"></i> <span>Search Monitored</span>';
|
||
}
|
||
}
|
||
},
|
||
|
||
async updateDetailInfoBar() {
|
||
const pathEl = document.getElementById('requestarr-tv-ib-path');
|
||
const statusEl = document.getElementById('requestarr-tv-ib-status');
|
||
const episodesEl = document.getElementById('requestarr-tv-ib-episodes');
|
||
if (!pathEl || !statusEl) return;
|
||
|
||
const tmdbId = this.currentSeries && (this.currentSeries.tmdb_id || this.currentSeries.id);
|
||
if (!tmdbId) return;
|
||
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
const isTVHuntInstance = decoded.appType === 'tv_hunt';
|
||
document.querySelectorAll('.mh-tv-hunt-only').forEach(el => { el.style.display = isTVHuntInstance ? '' : 'none'; });
|
||
const seriesMonitorWrap = document.getElementById('requestarr-tv-series-monitor-wrap');
|
||
if (seriesMonitorWrap) seriesMonitorWrap.style.display = 'none';
|
||
if (!decoded.name) {
|
||
this.seriesStatus = null;
|
||
pathEl.textContent = '-';
|
||
statusEl.innerHTML = '<span class="mh-badge mh-badge-warn">Not in Collection</span>';
|
||
if (episodesEl) episodesEl.textContent = '-';
|
||
this.updateSeasonCountBadges();
|
||
this.updateEpisodeMonitorIcons();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await this.checkSeriesStatus(tmdbId, this.selectedInstanceName);
|
||
this.seriesStatus = data;
|
||
|
||
const actionsEl = document.getElementById('requestarr-tv-detail-actions');
|
||
|
||
const isTVHunt = decoded.appType === 'tv_hunt';
|
||
const editBtnEl = document.getElementById('requestarr-tv-detail-edit');
|
||
const deleteBtnEl = document.getElementById('requestarr-tv-detail-delete');
|
||
const refreshBtnEl = document.getElementById('requestarr-tv-detail-refresh');
|
||
const searchMonBtnEl = document.getElementById('requestarr-tv-search-monitored');
|
||
|
||
if (data.exists) {
|
||
if (actionsEl && !isTVHunt) {
|
||
actionsEl.style.display = 'none';
|
||
}
|
||
const seriesMonitorWrap = document.getElementById('requestarr-tv-series-monitor-wrap');
|
||
const seriesMonitorBtn = document.getElementById('requestarr-tv-series-monitor-btn');
|
||
if (isTVHunt && seriesMonitorWrap && seriesMonitorBtn) {
|
||
seriesMonitorWrap.style.display = '';
|
||
const monitored = !!data.monitored;
|
||
seriesMonitorBtn.classList.toggle('mh-monitor-on', monitored);
|
||
seriesMonitorBtn.classList.toggle('mh-monitor-off', !monitored);
|
||
seriesMonitorBtn.querySelector('i').className = monitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
}
|
||
// Build path: root_folder + series title
|
||
let displayPath = data.path || data.root_folder_path || '-';
|
||
if (displayPath && displayPath !== '-' && this.currentSeries) {
|
||
const seriesTitle = this.currentSeries.name || this.currentSeries.title || '';
|
||
if (seriesTitle && !displayPath.includes(seriesTitle)) {
|
||
displayPath = displayPath.replace(/\/+$/, '') + '/' + seriesTitle;
|
||
}
|
||
}
|
||
pathEl.textContent = displayPath;
|
||
const avail = data.available_episodes ?? 0;
|
||
const total = data.total_episodes ?? 0;
|
||
const missing = data.missing_episodes ?? 0;
|
||
|
||
let statusClass = 'mh-badge-warn';
|
||
let statusLabel = 'Requested';
|
||
let statusIcon = 'fa-clock';
|
||
if (total > 0 && avail === total) {
|
||
statusClass = 'mh-badge-ok';
|
||
statusLabel = 'Complete';
|
||
statusIcon = 'fa-check-circle';
|
||
} else if (missing > 0) {
|
||
statusLabel = `${missing} missing`;
|
||
}
|
||
statusEl.innerHTML = `<span class="mh-badge ${statusClass}"><i class="fas ${statusIcon}"></i> ${statusLabel}</span>`;
|
||
if (episodesEl) episodesEl.textContent = `${avail} / ${total}`;
|
||
|
||
// Show toolbar buttons for items in collection
|
||
if (editBtnEl && isTVHunt) editBtnEl.style.display = '';
|
||
if (deleteBtnEl && isTVHunt) deleteBtnEl.style.display = '';
|
||
if (refreshBtnEl && isTVHunt) refreshBtnEl.style.display = '';
|
||
if (searchMonBtnEl && isTVHunt) searchMonBtnEl.style.display = '';
|
||
|
||
this.updateSeasonCountBadges();
|
||
this.updateEpisodeMonitorIcons();
|
||
} else {
|
||
if (actionsEl && !isTVHunt) actionsEl.style.display = '';
|
||
if (seriesMonitorWrap) seriesMonitorWrap.style.display = 'none';
|
||
pathEl.textContent = '-';
|
||
statusEl.innerHTML = '<span class="mh-badge mh-badge-warn">Not in Collection</span>';
|
||
if (episodesEl) episodesEl.textContent = '-';
|
||
|
||
// Hide toolbar buttons when not in collection
|
||
if (editBtnEl) editBtnEl.style.display = 'none';
|
||
if (deleteBtnEl) deleteBtnEl.style.display = 'none';
|
||
if (refreshBtnEl) refreshBtnEl.style.display = 'none';
|
||
if (searchMonBtnEl) searchMonBtnEl.style.display = 'none';
|
||
|
||
this.updateSeasonCountBadges();
|
||
this.updateEpisodeMonitorIcons();
|
||
}
|
||
} catch (e) {
|
||
const actionsEl = document.getElementById('requestarr-tv-detail-actions');
|
||
if (actionsEl && decoded.appType !== 'tv_hunt') actionsEl.style.display = '';
|
||
pathEl.textContent = '-';
|
||
statusEl.innerHTML = '<span class="mh-badge mh-badge-warn">Error</span>';
|
||
if (episodesEl) episodesEl.textContent = '-';
|
||
this.updateSeasonCountBadges();
|
||
this.updateEpisodeMonitorIcons();
|
||
}
|
||
},
|
||
|
||
collapseExpandedSeasons() {
|
||
const items = document.querySelectorAll('.requestarr-tv-season-item.expanded');
|
||
items.forEach(item => {
|
||
item.classList.remove('expanded');
|
||
const body = item.nextElementSibling;
|
||
if (body && body.classList.contains('requestarr-tv-season-episodes')) {
|
||
body.remove();
|
||
}
|
||
});
|
||
},
|
||
|
||
getTVHuntInstanceId() {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'tv_hunt') return null;
|
||
const inst = this.tvInstances.find(i => i.compoundValue === this.selectedInstanceName);
|
||
return inst && inst.id ? String(inst.id) : null;
|
||
},
|
||
|
||
buildEpisodeStatusMap(seasonNum) {
|
||
const map = {};
|
||
if (!this.seriesStatus || !this.seriesStatus.exists || !this.seriesStatus.seasons) return map;
|
||
const season = this.seriesStatus.seasons.find(s =>
|
||
(s.season_number ?? s.seasonNumber) === seasonNum
|
||
);
|
||
if (!season) return map;
|
||
const eps = season.episodes || [];
|
||
eps.forEach(ep => {
|
||
const epNum = ep.episode_number ?? ep.episodeNumber;
|
||
const avail = (ep.status || '').toLowerCase() === 'available' || !!ep.file_path || !!ep.episodeFile;
|
||
const quality = ep.quality || ep.file_quality || (ep.episodeFile && ep.episodeFile.quality && ep.episodeFile.quality.quality && ep.episodeFile.quality.quality.name);
|
||
if (epNum != null && avail) map[epNum] = quality ? { quality } : true;
|
||
});
|
||
return map;
|
||
},
|
||
|
||
buildEpisodeMonitoredMap(seasonNum) {
|
||
const map = {};
|
||
if (!this.seriesStatus || !this.seriesStatus.exists || !this.seriesStatus.seasons) return map;
|
||
const season = this.seriesStatus.seasons.find(s =>
|
||
(s.season_number ?? s.seasonNumber) === seasonNum
|
||
);
|
||
if (!season) return map;
|
||
(season.episodes || []).forEach(ep => {
|
||
const epNum = ep.episode_number ?? ep.episodeNumber;
|
||
if (epNum != null) map[epNum] = !!ep.monitored;
|
||
});
|
||
return map;
|
||
},
|
||
|
||
async requestSeason(tmdbId, seasonNum) {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (!decoded.name) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('No instance selected.', 'error');
|
||
}
|
||
return;
|
||
}
|
||
if (decoded.appType === 'sonarr') {
|
||
// Grey out the button immediately
|
||
const btn = document.querySelector(`.season-action-btn[data-season="${seasonNum}"]`);
|
||
if (btn) btn.classList.add('pressed');
|
||
|
||
try {
|
||
const r = await fetch('./api/requestarr/sonarr/season-search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tmdb_id: tmdbId, instance: decoded.name, season_number: seasonNum }),
|
||
});
|
||
const data = await r.json();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.success ? (data.message || 'Season search started') : (data.message || 'Request failed'), data.success ? 'success' : 'error');
|
||
}
|
||
if (data.success) this.updateDetailInfoBar();
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Request failed.', 'error');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
if (!instanceId) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('No TV Hunt instance selected.', 'error');
|
||
}
|
||
return;
|
||
}
|
||
const title = (this.currentSeries && (this.currentSeries.title || this.currentSeries.name)) || '';
|
||
if (!title) return;
|
||
|
||
// Grey out the button immediately
|
||
const btn = document.querySelector(`.season-action-btn[data-season="${seasonNum}"]`);
|
||
if (btn) btn.classList.add('pressed');
|
||
|
||
try {
|
||
const r = await fetch(`./api/tv-hunt/request?instance_id=${instanceId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
series_title: title,
|
||
season_number: seasonNum,
|
||
tmdb_id: tmdbId,
|
||
search_type: 'season',
|
||
instance_id: instanceId,
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.success ? (data.message || 'Season search sent!') : (data.message || 'Request failed'), data.success ? 'success' : 'error');
|
||
}
|
||
if (data.success) this.updateDetailInfoBar();
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Request failed.', 'error');
|
||
}
|
||
}
|
||
},
|
||
|
||
async toggleMonitor(tmdbId, seasonNum, episodeNum) {
|
||
console.log('toggleMonitor called:', { tmdbId, seasonNum, episodeNum });
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
if (!instanceId) {
|
||
if (window.huntarrUI?.showNotification) {
|
||
window.huntarrUI.showNotification('Select a TV Hunt instance to toggle monitor.', 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Ensure we have current status
|
||
if (!this.seriesStatus) {
|
||
console.log('No seriesStatus, fetching...');
|
||
await this.updateDetailInfoBar();
|
||
}
|
||
if (!this.seriesStatus) {
|
||
console.error('Failed to get seriesStatus for toggleMonitor');
|
||
return;
|
||
}
|
||
|
||
const currentMonitored = (() => {
|
||
if (seasonNum != null && episodeNum != null) {
|
||
const map = this.buildEpisodeMonitoredMap(seasonNum);
|
||
return !!map[episodeNum];
|
||
}
|
||
if (seasonNum != null) {
|
||
const season = this.seriesStatus.seasons
|
||
? this.seriesStatus.seasons.find(s => (s.season_number ?? s.seasonNumber) === seasonNum)
|
||
: null;
|
||
return season ? !!season.monitored : false;
|
||
}
|
||
// Series level
|
||
console.log('Series-level toggle, current monitored:', this.seriesStatus.monitored);
|
||
return !!this.seriesStatus.monitored;
|
||
})();
|
||
|
||
const newMonitored = !currentMonitored;
|
||
console.log('New monitored state will be:', newMonitored);
|
||
|
||
// Optimistic UI update for the series button if it's a series-level toggle
|
||
if (seasonNum == null && episodeNum == null) {
|
||
const btn = document.getElementById('requestarr-tv-series-monitor-btn');
|
||
if (btn) {
|
||
const icon = btn.querySelector('i');
|
||
if (icon) icon.className = newMonitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
}
|
||
}
|
||
|
||
const body = { monitored: newMonitored, instance_id: instanceId };
|
||
if (seasonNum !== undefined && seasonNum !== null) body.season_number = seasonNum;
|
||
if (episodeNum !== undefined && episodeNum !== null) body.episode_number = episodeNum;
|
||
|
||
try {
|
||
const r = await fetch(`./api/tv-hunt/collection/${tmdbId}/monitor?instance_id=${instanceId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!r.ok) {
|
||
const errData = await r.json().catch(() => ({}));
|
||
throw new Error(errData.error || 'Update failed');
|
||
}
|
||
|
||
console.log('Monitor toggle success, refreshing info bar...');
|
||
// Full refresh from server
|
||
await this.updateDetailInfoBar();
|
||
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(newMonitored ? 'Monitor on' : 'Monitor off', 'success');
|
||
}
|
||
} catch (e) {
|
||
console.error('toggleMonitor error:', e);
|
||
// Revert optimistic update on failure
|
||
if (seasonNum == null && episodeNum == null) {
|
||
const btn = document.getElementById('requestarr-tv-series-monitor-btn');
|
||
if (btn) {
|
||
const icon = btn.querySelector('i');
|
||
if (icon) icon.className = currentMonitored ? 'fas fa-bookmark' : 'far fa-bookmark';
|
||
}
|
||
}
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to update monitor: ' + e.message, 'error');
|
||
}
|
||
}
|
||
},
|
||
|
||
async requestEpisode(tmdbId, seasonNum, episodeNum) {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (!decoded.name) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('No instance selected.', 'error');
|
||
}
|
||
return;
|
||
}
|
||
if (decoded.appType === 'sonarr') {
|
||
// Grey out the button immediately
|
||
const btn = document.querySelector(`.ep-request-btn[data-season="${seasonNum}"][data-episode="${episodeNum}"], .ep-upgrade-btn[data-season="${seasonNum}"][data-episode="${episodeNum}"]`);
|
||
if (btn) btn.classList.add('pressed');
|
||
|
||
try {
|
||
const r = await fetch('./api/requestarr/sonarr/episode-search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tmdb_id: tmdbId, instance: decoded.name, season_number: seasonNum, episode_number: episodeNum }),
|
||
});
|
||
const data = await r.json();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.success ? (data.message || 'Episode search started') : (data.message || 'Request failed'), data.success ? 'success' : 'error');
|
||
}
|
||
if (data.success) this.updateDetailInfoBar();
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Request failed.', 'error');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
if (!instanceId) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('No TV Hunt instance selected.', 'error');
|
||
}
|
||
return;
|
||
}
|
||
const title = (this.currentSeries && (this.currentSeries.title || this.currentSeries.name)) || '';
|
||
if (!title) return;
|
||
|
||
// Grey out the button immediately
|
||
const btn = document.querySelector(`.ep-request-btn[data-season="${seasonNum}"][data-episode="${episodeNum}"], .ep-upgrade-btn[data-season="${seasonNum}"][data-episode="${episodeNum}"]`);
|
||
if (btn) btn.classList.add('pressed');
|
||
|
||
try {
|
||
const r = await fetch(`./api/tv-hunt/request?instance_id=${instanceId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
series_title: title,
|
||
season_number: seasonNum,
|
||
episode_number: episodeNum,
|
||
tmdb_id: tmdbId,
|
||
search_type: 'episode',
|
||
instance_id: instanceId,
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.success ? (data.message || 'Episode search sent!') : (data.message || 'Request failed'), data.success ? 'success' : 'error');
|
||
}
|
||
if (data.success) this.updateDetailInfoBar();
|
||
} catch (e) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Request failed.', 'error');
|
||
}
|
||
}
|
||
},
|
||
|
||
setupErrorBackButton() {
|
||
const btn = document.getElementById('requestarr-tv-detail-back-error');
|
||
if (btn) btn.addEventListener('click', () => this.closeDetail());
|
||
},
|
||
|
||
async openEditModalForTVHunt() {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'tv_hunt' || !decoded.name) return;
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
if (!instanceId) return;
|
||
|
||
const series = this.currentSeries;
|
||
const status = this.seriesStatus || null;
|
||
if (!series) return;
|
||
|
||
const title = this.escapeHtml(series.name || series.title || '');
|
||
const tmdbId = series.tmdb_id || series.id;
|
||
|
||
let profiles = [], rootFolders = [];
|
||
try {
|
||
const [profResp, rfResp] = await Promise.all([
|
||
fetch(`./api/tv-hunt/profiles?instance_id=${instanceId}`),
|
||
fetch(`./api/tv-hunt/root-folders?instance_id=${instanceId}`)
|
||
]);
|
||
const profData = await profResp.json();
|
||
profiles = profData.profiles || profData || [];
|
||
const rfData = await rfResp.json();
|
||
rootFolders = rfData.root_folders || rfData || [];
|
||
} catch (err) {
|
||
console.error('[RequestarrTVDetail] Edit modal fetch error:', err);
|
||
}
|
||
|
||
const currentProfile = (status && status.quality_profile) || '';
|
||
const currentRoot = (status && (status.path || status.root_folder_path)) || '';
|
||
|
||
const profileOpts = (Array.isArray(profiles) ? profiles : []).map(p => {
|
||
const name = p.name || 'Unknown';
|
||
const sel = name === currentProfile ? ' selected' : '';
|
||
return `<option value="${this.escapeHtml(name)}"${sel}>${this.escapeHtml(name)}${p.is_default ? ' (Default)' : ''}</option>`;
|
||
}).join('');
|
||
|
||
const rfOpts = (Array.isArray(rootFolders) ? rootFolders : []).map(rf => {
|
||
const path = rf.path || '';
|
||
const sel = path === currentRoot ? ' selected' : '';
|
||
return `<option value="${this.escapeHtml(path)}"${sel}>${this.escapeHtml(path)}${rf.is_default ? ' (Default)' : ''}</option>`;
|
||
}).join('');
|
||
|
||
const html =
|
||
'<div class="mh-modal-backdrop" id="mh-edit-modal">' +
|
||
'<div class="mh-modal">' +
|
||
'<div class="mh-modal-header">' +
|
||
'<h3><i class="fas fa-wrench"></i> Edit \u2014 ' + title + '</h3>' +
|
||
'<button class="mh-modal-x" id="mh-edit-close">×</button>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-body">' +
|
||
'<div class="mh-form-row"><label>Root Folder</label><select id="mh-edit-root-folder" class="mh-select">' + rfOpts + '</select></div>' +
|
||
'<div class="mh-form-row"><label>Quality Profile</label><select id="mh-edit-quality-profile" class="mh-select">' + profileOpts + '</select></div>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-footer">' +
|
||
'<button class="mh-btn mh-btn-secondary" id="mh-edit-cancel">Cancel</button>' +
|
||
'<button class="mh-btn mh-btn-primary" id="mh-edit-save">Save</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
const existing = document.getElementById('mh-edit-modal');
|
||
if (existing) existing.remove();
|
||
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
|
||
const self = this;
|
||
document.getElementById('mh-edit-close').addEventListener('click', () => { document.getElementById('mh-edit-modal').remove(); });
|
||
document.getElementById('mh-edit-cancel').addEventListener('click', () => { document.getElementById('mh-edit-modal').remove(); });
|
||
document.getElementById('mh-edit-modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'mh-edit-modal') document.getElementById('mh-edit-modal').remove();
|
||
});
|
||
document.getElementById('mh-edit-save').addEventListener('click', async () => {
|
||
const rootFolder = document.getElementById('mh-edit-root-folder').value;
|
||
const qualityProfile = document.getElementById('mh-edit-quality-profile').value;
|
||
const saveBtn = document.getElementById('mh-edit-save');
|
||
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; }
|
||
try {
|
||
const resp = await fetch(`./api/tv-hunt/collection/update?instance_id=${instanceId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tmdb_id: tmdbId, root_folder: rootFolder, quality_profile: qualityProfile })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
const modal = document.getElementById('mh-edit-modal');
|
||
if (modal) modal.remove();
|
||
self.updateDetailInfoBar();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Series updated successfully.', 'success');
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Save failed: ' + (data.error || 'Unknown error'), 'error');
|
||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
|
||
}
|
||
} catch (err) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Save failed: ' + err.message, 'error');
|
||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
|
||
}
|
||
});
|
||
},
|
||
|
||
openDeleteModalForTVHunt() {
|
||
const decoded = _decodeInstanceValue(this.selectedInstanceName || '');
|
||
if (decoded.appType !== 'tv_hunt' || !decoded.name) return;
|
||
const instanceId = this.getTVHuntInstanceId();
|
||
if (!instanceId) return;
|
||
|
||
const series = this.currentSeries;
|
||
if (!series) return;
|
||
const tmdbId = series.tmdb_id || series.id;
|
||
const title = this.escapeHtml(series.name || series.title || '');
|
||
const self = this;
|
||
|
||
const html =
|
||
'<div class="mh-modal-backdrop" id="mh-delete-modal">' +
|
||
'<div class="mh-modal">' +
|
||
'<div class="mh-modal-header">' +
|
||
'<h3><i class="fas fa-trash-alt" style="color:#ef4444;"></i> Delete \u2014 ' + title + '</h3>' +
|
||
'<button class="mh-modal-x" id="mh-delete-close">×</button>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-body">' +
|
||
'<p>Are you sure you want to remove <strong>' + title + '</strong> from your TV Hunt collection?</p>' +
|
||
'<p style="color:#94a3b8;font-size:13px;">This will not delete any downloaded files.</p>' +
|
||
'</div>' +
|
||
'<div class="mh-modal-footer">' +
|
||
'<button class="mh-btn mh-btn-secondary" id="mh-delete-cancel">Cancel</button>' +
|
||
'<button class="mh-btn mh-btn-danger" id="mh-delete-confirm">Delete</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
const existing = document.getElementById('mh-delete-modal');
|
||
if (existing) existing.remove();
|
||
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
|
||
document.getElementById('mh-delete-close').addEventListener('click', () => { document.getElementById('mh-delete-modal').remove(); });
|
||
document.getElementById('mh-delete-cancel').addEventListener('click', () => { document.getElementById('mh-delete-modal').remove(); });
|
||
document.getElementById('mh-delete-modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'mh-delete-modal') document.getElementById('mh-delete-modal').remove();
|
||
});
|
||
document.getElementById('mh-delete-confirm').addEventListener('click', async () => {
|
||
const confirmBtn = document.getElementById('mh-delete-confirm');
|
||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting...'; }
|
||
try {
|
||
const resp = await fetch(`./api/tv-hunt/collection/${tmdbId}?instance_id=${instanceId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
const modal = document.getElementById('mh-delete-modal');
|
||
if (modal) modal.remove();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification(title + ' removed from collection.', 'success');
|
||
self.closeDetail();
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Delete failed: ' + (data.error || 'Unknown error'), 'error');
|
||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Delete'; }
|
||
}
|
||
} catch (err) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Delete failed: ' + err.message, 'error');
|
||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Delete'; }
|
||
}
|
||
});
|
||
},
|
||
|
||
getLoadingHTML() {
|
||
return `
|
||
<div class="movie-detail-loading">
|
||
<i class="fas fa-spinner fa-spin"></i>
|
||
<p>Loading series details...</p>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
getErrorHTML(message) {
|
||
return `
|
||
<div class="movie-detail-loading">
|
||
<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i>
|
||
<p style="color: #ef4444;">${this.escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => window.RequestarrTVDetail.init());
|
||
} else {
|
||
window.RequestarrTVDetail.init();
|
||
}
|
||
})();
|
||
|
||
|
||
/* === modules/features/nzb-hunt.js === */
|
||
/**
|
||
* NZB Hunt - Standalone JavaScript module
|
||
* Independent: does not share state with Movie Hunt, Requestarr, or any other module.
|
||
* Manages NZB Home, Activity (coming soon), and Settings (Folders + Servers).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
function _parseJsonOrThrow(r) {
|
||
return r.json().then(function (data) {
|
||
if (!r.ok) throw new Error(data && (data.error || data.message) || 'Request failed');
|
||
return data;
|
||
});
|
||
}
|
||
|
||
window.NzbHunt = {
|
||
currentTab: 'queue',
|
||
_servers: [],
|
||
_categories: [],
|
||
_editIndex: null, // null = add, number = edit
|
||
_catEditIndex: null, // null = add, number = edit
|
||
_pollTimer: null,
|
||
_paused: false,
|
||
_selectedIds: {},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Initialization
|
||
────────────────────────────────────────────── */
|
||
init: function () {
|
||
var self = this;
|
||
this.setupTabs();
|
||
this.showTab('queue');
|
||
|
||
// Wire up Refresh buttons
|
||
var queueRefresh = document.querySelector('#nzb-hunt-section [data-panel="queue"] .nzb-queue-actions .nzb-btn');
|
||
if (queueRefresh) queueRefresh.addEventListener('click', function () { self._fetchQueueAndStatus(); });
|
||
|
||
var historyRefresh = document.querySelector('#nzb-hunt-section [data-panel="history"] .nzb-queue-actions .nzb-btn[title="Refresh"]');
|
||
if (historyRefresh) historyRefresh.addEventListener('click', function () { self._fetchHistory(); });
|
||
|
||
var historyClear = document.querySelector('#nzb-hunt-section [data-panel="history"] .nzb-btn-danger');
|
||
if (historyClear) historyClear.addEventListener('click', function () { self._clearHistory(); });
|
||
|
||
// Wire up Warnings dismiss all
|
||
var warnDismiss = document.getElementById('nzb-warnings-dismiss-all');
|
||
if (warnDismiss) warnDismiss.addEventListener('click', function () { self._dismissAllWarnings(); });
|
||
|
||
// Wire up Pause / Resume ALL button (actually hits backend)
|
||
var pauseBtn = document.getElementById('nzb-pause-btn');
|
||
if (pauseBtn) {
|
||
pauseBtn.addEventListener('click', function () {
|
||
self._paused = !self._paused;
|
||
var icon = pauseBtn.querySelector('i');
|
||
if (icon) icon.className = self._paused ? 'fas fa-play' : 'fas fa-pause';
|
||
pauseBtn.title = self._paused ? 'Resume all downloads' : 'Pause all downloads';
|
||
fetch(self._paused ? './api/nzb-hunt/queue/pause-all' : './api/nzb-hunt/queue/resume-all', { method: 'POST' })
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (e) {
|
||
console.error('[NzbHunt] Pause/resume error:', e);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(e.message || 'Failed to pause/resume', 'error');
|
||
}
|
||
self._fetchQueueAndStatus();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Wire up speed limit popover
|
||
this._setupSpeedLimit();
|
||
|
||
// Wire up modal controls
|
||
this._setupPrefsModal();
|
||
|
||
// Load display prefs from server, then start polling with correct rates
|
||
this._loadDisplayPrefs(function () {
|
||
self._fetchQueueAndStatus();
|
||
self._fetchHistory();
|
||
self._applyRefreshRates();
|
||
console.log('[NzbHunt] Home initialized – polling started');
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Queue & Status Polling
|
||
────────────────────────────────────────────── */
|
||
_queueFingerprint: '',
|
||
|
||
_buildQueueFingerprint: function (queue) {
|
||
if (!queue || !queue.length) return '[]';
|
||
var parts = [];
|
||
for (var i = 0; i < queue.length; i++) {
|
||
var q = queue[i];
|
||
parts.push(q.id + '|' + q.state + '|' + Math.round(q.progress_pct || 0) + '|' +
|
||
(q.downloaded_bytes || 0) + '|' + (q.speed_bps || 0) + '|' +
|
||
(q.priority || 'normal') + '|' + (q.time_left || '') + '|' +
|
||
(q.status_message || '') + '|' + (q.completed_files || 0));
|
||
}
|
||
return parts.join(';');
|
||
},
|
||
|
||
_fetchQueueAndStatus: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/poll?t=' + Date.now())
|
||
.then(function (r) { return r.ok ? r.json() : Promise.resolve({ status: {}, queue: [] }); })
|
||
.then(function (data) {
|
||
var statusData = data.status || {};
|
||
var queueList = data.queue || [];
|
||
self._lastStatus = statusData;
|
||
self._lastQueue = queueList;
|
||
|
||
// Only rebuild DOM if queue data actually changed
|
||
var fp = self._buildQueueFingerprint(queueList);
|
||
if (fp !== self._queueFingerprint) {
|
||
self._queueFingerprint = fp;
|
||
self._renderQueue(queueList);
|
||
}
|
||
|
||
// Status bar is lightweight text updates, always safe
|
||
self._updateStatusBar(statusData);
|
||
self._updateQueueBadge(queueList);
|
||
var hBadge = document.getElementById('nzb-history-count');
|
||
if (hBadge) hBadge.textContent = statusData.history_count || 0;
|
||
self._updateWarnings(statusData.warnings || []);
|
||
}).catch(function (err) {
|
||
console.error('[NzbHunt] Poll error:', err);
|
||
});
|
||
},
|
||
|
||
_updateStatusBar: function (status) {
|
||
var speedEl = document.getElementById('nzb-speed');
|
||
var etaEl = document.getElementById('nzb-eta');
|
||
var remainEl = document.getElementById('nzb-remaining');
|
||
var freeEl = document.getElementById('nzb-free-space');
|
||
|
||
if (speedEl) speedEl.textContent = status.speed_human || '0 B/s';
|
||
if (etaEl) etaEl.textContent = status.eta_human || this._currentEta || '--';
|
||
if (remainEl) remainEl.textContent = status.remaining_human || this._currentRemaining || '0 B';
|
||
if (freeEl) freeEl.textContent = status.free_space_human || '--';
|
||
|
||
// Update speed limit badge
|
||
var limitBadge = document.getElementById('nzb-speed-limit-badge');
|
||
if (limitBadge) {
|
||
if (status.speed_limit_bps && status.speed_limit_bps > 0) {
|
||
limitBadge.textContent = '⚡ ' + this._formatBytes(status.speed_limit_bps) + '/s';
|
||
limitBadge.style.display = 'inline';
|
||
} else {
|
||
limitBadge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Sync pause button state with backend
|
||
if (status.paused_global !== undefined) {
|
||
this._paused = status.paused_global;
|
||
var pauseBtn = document.getElementById('nzb-pause-btn');
|
||
if (pauseBtn) {
|
||
var icon = pauseBtn.querySelector('i');
|
||
if (icon) icon.className = this._paused ? 'fas fa-play' : 'fas fa-pause';
|
||
pauseBtn.title = this._paused ? 'Resume all downloads' : 'Pause all downloads';
|
||
}
|
||
}
|
||
|
||
// Show warning when NZB Hunt is not configured as a download client (hide when it is)
|
||
var warnEl = document.getElementById('nzb-client-warning');
|
||
if (warnEl) {
|
||
var hasNzbHunt = status.nzb_hunt_configured_as_client === true || status.nzb_hunt_configured_as_client === 'true';
|
||
warnEl.style.display = hasNzbHunt ? 'none' : 'flex';
|
||
}
|
||
|
||
// Update Active Connections (number + hover tooltip with per-server breakdown)
|
||
var activeEl = document.getElementById('nzb-active-connections-value');
|
||
var tooltipEl = document.getElementById('nzb-active-connections-tooltip');
|
||
if (activeEl) {
|
||
var connStats = status.connection_stats || [];
|
||
var totalActive = connStats.reduce(function (sum, s) { return sum + (s.active || 0); }, 0);
|
||
var totalMax = connStats.reduce(function (sum, s) { return sum + (s.max || 0); }, 0);
|
||
if (connStats.length === 0) {
|
||
activeEl.textContent = '0';
|
||
} else {
|
||
activeEl.textContent = totalMax > 0 ? totalActive + ' / ' + totalMax : String(totalActive);
|
||
}
|
||
}
|
||
if (tooltipEl) {
|
||
var connStats = status.connection_stats || [];
|
||
if (connStats.length === 0) {
|
||
tooltipEl.textContent = 'Configure servers in Settings';
|
||
} else {
|
||
var rows = connStats.map(function (s) {
|
||
return '<span class="nzb-tooltip-server">' + (s.name || s.host || 'Server') + ': ' + (s.active || 0) + ' / ' + (s.max || 0) + '</span>';
|
||
});
|
||
tooltipEl.innerHTML = '<strong>Connections per server</strong><div class="nzb-tooltip-servers">' + rows.join('') + '</div>';
|
||
}
|
||
}
|
||
},
|
||
|
||
_updateQueueBadge: function (queue) {
|
||
var badge = document.getElementById('nzb-queue-count');
|
||
if (badge) badge.textContent = queue.length;
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Queue Rendering
|
||
────────────────────────────────────────────── */
|
||
_priorityLabel: function (p) {
|
||
var map = { force: 'Force', high: 'High', normal: 'Normal', low: 'Low', stop: 'Stop' };
|
||
return map[(p || 'normal').toLowerCase()] || 'Normal';
|
||
},
|
||
|
||
_priorityClass: function (p) {
|
||
return 'nzb-priority-' + (p || 'normal').toLowerCase();
|
||
},
|
||
|
||
_getSelectedIds: function () {
|
||
var ids = [];
|
||
for (var k in this._selectedIds) {
|
||
if (this._selectedIds[k]) ids.push(k);
|
||
}
|
||
return ids;
|
||
},
|
||
|
||
_updateSelectAllState: function () {
|
||
var cb = document.getElementById('nzb-select-all');
|
||
if (!cb) return;
|
||
var rows = document.querySelectorAll('.nzb-queue-row-cb');
|
||
var total = rows.length;
|
||
var checked = 0;
|
||
rows.forEach(function (r) { if (r.checked) checked++; });
|
||
cb.checked = total > 0 && checked === total;
|
||
cb.indeterminate = checked > 0 && checked < total;
|
||
},
|
||
|
||
_updateMassActionBar: function () {
|
||
var bar = document.getElementById('nzb-mass-action-bar');
|
||
if (!bar) return;
|
||
var selected = this._getSelectedIds();
|
||
if (selected.length > 0) {
|
||
bar.style.display = 'flex';
|
||
var countEl = document.getElementById('nzb-mass-count');
|
||
if (countEl) countEl.textContent = selected.length + ' selected';
|
||
} else {
|
||
bar.style.display = 'none';
|
||
}
|
||
},
|
||
|
||
_onMassPriorityChange: function (priority) {
|
||
var self = this;
|
||
var ids = this._getSelectedIds();
|
||
if (!ids.length || !priority) return;
|
||
fetch('./api/nzb-hunt/queue/bulk/priority', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids: ids, priority: priority })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (err) { console.error('[NzbHunt] Bulk priority error:', err); });
|
||
},
|
||
|
||
_onMassDelete: function () {
|
||
var self = this;
|
||
var ids = this._getSelectedIds();
|
||
if (!ids.length) return;
|
||
if (!confirm('Remove ' + ids.length + ' item' + (ids.length > 1 ? 's' : '') + ' from the queue?')) return;
|
||
fetch('./api/nzb-hunt/queue/bulk/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids: ids })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () {
|
||
self._selectedIds = {};
|
||
self._fetchQueueAndStatus();
|
||
})
|
||
.catch(function (err) { console.error('[NzbHunt] Bulk delete error:', err); });
|
||
},
|
||
|
||
_onSinglePriorityChange: function (id, priority) {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/queue/' + id + '/priority', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ priority: priority })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (err) { console.error('[NzbHunt] Set priority error:', err); });
|
||
},
|
||
|
||
_renderQueue: function (queue) {
|
||
var body = document.getElementById('nzb-queue-body');
|
||
if (!body) return;
|
||
|
||
if (!queue || queue.length === 0) {
|
||
this._selectedIds = {};
|
||
body.innerHTML =
|
||
'<div class="nzb-queue-empty">' +
|
||
'<div class="nzb-queue-empty-icon"><i class="fas fa-inbox"></i></div>' +
|
||
'<h3>Queue is empty</h3>' +
|
||
'<p>Downloads will appear here once NZB Hunt is connected to your Usenet setup.</p>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
var self = this;
|
||
var filter = (this._queueFilter || '').toLowerCase();
|
||
var filtered = filter
|
||
? queue.filter(function (q) { return (q.name || '').toLowerCase().indexOf(filter) !== -1; })
|
||
: queue;
|
||
var perPage = this._queuePerPage || 20;
|
||
var totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
|
||
if (this._queuePage > totalPages) this._queuePage = totalPages;
|
||
var start = (this._queuePage - 1) * perPage;
|
||
var page = filtered.slice(start, start + perPage);
|
||
|
||
var totalRemaining = 0;
|
||
filtered.forEach(function (q) {
|
||
var tb = q.total_bytes || 0;
|
||
var db = Math.min(q.downloaded_bytes || 0, tb);
|
||
totalRemaining += Math.max(0, tb - db);
|
||
});
|
||
|
||
// Prune _selectedIds: remove IDs no longer in the full queue
|
||
var queueIdSet = {};
|
||
queue.forEach(function (q) { queueIdSet[q.id] = true; });
|
||
for (var sid in self._selectedIds) {
|
||
if (!queueIdSet[sid]) delete self._selectedIds[sid];
|
||
}
|
||
|
||
var html =
|
||
'<table class="nzb-queue-table">' +
|
||
'<thead><tr>' +
|
||
'<th class="nzb-col-check"><input type="checkbox" id="nzb-select-all" title="Select all" /></th>' +
|
||
'<th class="nzb-col-name">Name</th>' +
|
||
'<th class="nzb-col-cat">Category</th>' +
|
||
'<th class="nzb-col-pct">Progress</th>' +
|
||
'<th class="nzb-col-size">Size</th>' +
|
||
'<th class="nzb-col-priority">Priority</th>' +
|
||
'<th class="nzb-col-eta">ETA</th>' +
|
||
'<th class="nzb-col-status">Status</th>' +
|
||
'<th class="nzb-col-actions"></th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
page.forEach(function (item) {
|
||
var progress = item.progress_pct || 0;
|
||
var stateClass = 'nzb-item-' + (item.state || 'queued');
|
||
var stateIcon = self._stateIcon(item.state);
|
||
var stateLabel = self._stateLabel(item.state);
|
||
var isActivelyDownloading = (item.state === 'downloading' && progress < 100);
|
||
var timeLeft = isActivelyDownloading ? (item.time_left || '—') : '—';
|
||
var db = item.downloaded_bytes || 0;
|
||
var tb = item.total_bytes || 0;
|
||
if (tb > 0 && db > tb) db = tb;
|
||
var downloaded = self._formatBytes(db);
|
||
var totalSize = self._formatBytes(tb);
|
||
var name = self._escHtml(item.name || 'Unknown');
|
||
var catLabel = item.category ? self._escHtml(String(item.category)) : '—';
|
||
var isChecked = !!self._selectedIds[item.id];
|
||
var priVal = (item.priority || 'normal').toLowerCase();
|
||
var priLabel = self._priorityLabel(priVal);
|
||
var priClass = self._priorityClass(priVal);
|
||
|
||
// Build status display
|
||
var failedSegs = item.failed_segments || 0;
|
||
var tooltipText = '';
|
||
var statusHtml = '<span class="nzb-status-label"><i class="' + stateIcon + '"></i> ';
|
||
|
||
if (item.state === 'assembling') {
|
||
var cf = item.completed_files || 0;
|
||
var tf = item.total_files || 0;
|
||
statusHtml += 'Assembling</span><span class="nzb-status-sub nzb-status-msg">' + cf + '/' + tf + ' files</span>';
|
||
if (failedSegs > 0) tooltipText = 'par2 repair will be needed (' + failedSegs + ' missing segments)';
|
||
} else if (item.state === 'extracting') {
|
||
statusHtml += stateLabel + '</span>';
|
||
if (item.status_message) {
|
||
statusHtml += '<span class="nzb-status-sub nzb-status-msg">' + self._escHtml(item.status_message) + '</span>';
|
||
tooltipText = item.status_message;
|
||
}
|
||
} else {
|
||
statusHtml += stateLabel + '</span>';
|
||
if (item.status_message && item.state !== 'downloading') {
|
||
var msgClass = failedSegs > 0 ? ' nzb-status-msg-warn' : ' nzb-status-msg';
|
||
statusHtml += '<span class="nzb-status-sub' + msgClass + '">' + self._escHtml(item.status_message) + '</span>';
|
||
tooltipText = item.status_message;
|
||
}
|
||
if (item.state === 'downloading' && item.completed_segments === 0 && item.speed_bps === 0) {
|
||
statusHtml += '<span class="nzb-status-sub nzb-status-msg">Connecting...</span>';
|
||
}
|
||
}
|
||
if (!tooltipText && item.error_message) {
|
||
tooltipText = item.error_message;
|
||
}
|
||
if (tooltipText) {
|
||
statusHtml = '<span class="nzb-status-with-tooltip" title="">' + statusHtml + '<div class="nzb-cell-tooltip">' + self._escHtml(tooltipText) + '</div></span>';
|
||
}
|
||
|
||
// Progress
|
||
var missingBytes = item.missing_bytes || 0;
|
||
var missingStr = '';
|
||
if (missingBytes > 0 && item.state === 'downloading') {
|
||
var mbMissing = missingBytes / (1024 * 1024);
|
||
missingStr = mbMissing >= 1024 ? (mbMissing / 1024).toFixed(1) + ' GB' :
|
||
mbMissing >= 1.0 ? mbMissing.toFixed(1) + ' MB' :
|
||
(missingBytes / 1024).toFixed(0) + ' KB';
|
||
}
|
||
var pctHtml = '<span class="nzb-progress-pct">' + progress.toFixed(1) + '%</span>';
|
||
if (missingStr) {
|
||
pctHtml += ' <i class="fas fa-exclamation-triangle nzb-missing-icon" title="' + _esc(missingStr + ' missing articles') + '"></i>';
|
||
}
|
||
|
||
html +=
|
||
'<tr class="nzb-queue-row ' + stateClass + (isChecked ? ' nzb-row-selected' : '') + '" data-nzb-id="' + item.id + '">' +
|
||
'<td class="nzb-col-check"><input type="checkbox" class="nzb-queue-row-cb" data-id="' + item.id + '"' + (isChecked ? ' checked' : '') + ' /></td>' +
|
||
'<td class="nzb-col-name" data-label="Name" title="' + name + '"><span class="nzb-cell-name">' + name + '</span></td>' +
|
||
'<td class="nzb-col-cat" data-label="Category"><span class="nzb-cell-cat">' + catLabel + '</span></td>' +
|
||
'<td class="nzb-col-pct" data-label="Progress">' + pctHtml + '</td>' +
|
||
'<td class="nzb-col-size" data-label="Size">' + downloaded + ' / ' + totalSize + '</td>' +
|
||
'<td class="nzb-col-priority ' + priClass + '" data-label="Priority">' +
|
||
'<select class="nzb-priority-select" data-id="' + item.id + '">' +
|
||
'<option value="force"' + (priVal === 'force' ? ' selected' : '') + '>Force</option>' +
|
||
'<option value="high"' + (priVal === 'high' ? ' selected' : '') + '>High</option>' +
|
||
'<option value="normal"' + (priVal === 'normal' ? ' selected' : '') + '>Normal</option>' +
|
||
'<option value="low"' + (priVal === 'low' ? ' selected' : '') + '>Low</option>' +
|
||
'<option value="stop"' + (priVal === 'stop' ? ' selected' : '') + '>Stop</option>' +
|
||
'</select>' +
|
||
'</td>' +
|
||
'<td class="nzb-col-eta" data-label="ETA">' + timeLeft + '</td>' +
|
||
'<td class="nzb-col-status" data-label="Status">' + statusHtml + '</td>' +
|
||
'<td class="nzb-col-actions" data-label="">' +
|
||
(item.state === 'downloading' || item.state === 'assembling' || item.state === 'queued' ?
|
||
'<button class="nzb-item-btn" title="Pause" data-action="pause" data-id="' + item.id + '"><i class="fas fa-pause"></i></button>' : '') +
|
||
(item.state === 'paused' ?
|
||
'<button class="nzb-item-btn" title="Resume" data-action="resume" data-id="' + item.id + '"><i class="fas fa-play"></i></button>' : '') +
|
||
'<button class="nzb-item-btn nzb-item-btn-danger" title="Remove" data-action="remove" data-id="' + item.id + '"><i class="fas fa-trash-alt"></i></button>' +
|
||
'</td>' +
|
||
'</tr>';
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
html = '<div class="nzb-table-scroll">' + html + '</div>';
|
||
|
||
// Mass action bar (hidden by default, shown when items selected)
|
||
var selCount = self._getSelectedIds().length;
|
||
html += '<div class="nzb-mass-action-bar" id="nzb-mass-action-bar" style="display:' + (selCount > 0 ? 'flex' : 'none') + ';">';
|
||
html += '<span class="nzb-mass-count" id="nzb-mass-count">' + selCount + ' selected</span>';
|
||
html += '<select class="nzb-mass-priority-select" id="nzb-mass-priority-select" title="Set priority for selected">';
|
||
html += '<option value="">Priority</option>';
|
||
html += '<option value="force">Force</option>';
|
||
html += '<option value="high">High</option>';
|
||
html += '<option value="normal">Normal</option>';
|
||
html += '<option value="low">Low</option>';
|
||
html += '<option value="stop">Stop</option>';
|
||
html += '</select>';
|
||
html += '<button class="nzb-mass-delete-btn" id="nzb-mass-delete-btn" title="Remove selected"><i class="fas fa-trash-alt"></i></button>';
|
||
html += '</div>';
|
||
|
||
html += '<div class="nzb-queue-footer">';
|
||
html += '<div class="nzb-hist-search"><i class="fas fa-search"></i><input type="text" id="nzb-queue-search-input" placeholder="Search" value="' + self._escHtml(this._queueFilter) + '" /></div>';
|
||
html += '<div class="nzb-hist-pagination">';
|
||
if (totalPages > 1) {
|
||
html += '<button data-queue-page="prev" ' + (this._queuePage <= 1 ? 'disabled' : '') + '>«</button>';
|
||
var pages = self._paginationRange(this._queuePage, totalPages);
|
||
for (var i = 0; i < pages.length; i++) {
|
||
if (pages[i] === '…') {
|
||
html += '<span>…</span>';
|
||
} else {
|
||
html += '<button data-queue-page="' + pages[i] + '" ' + (pages[i] === this._queuePage ? 'class="active"' : '') + '>' + pages[i] + '</button>';
|
||
}
|
||
}
|
||
html += '<button data-queue-page="next" ' + (this._queuePage >= totalPages ? 'disabled' : '') + '>»</button>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="nzb-hist-stats"><span><i class="fas fa-download"></i>' + self._formatBytes(totalRemaining) + ' Remaining</span><span>' + filtered.length + ' items</span></div>';
|
||
html += '</div>';
|
||
|
||
body.innerHTML = html;
|
||
|
||
// Wire up select-all checkbox
|
||
var selectAllCb = document.getElementById('nzb-select-all');
|
||
if (selectAllCb) {
|
||
selectAllCb.addEventListener('change', function () {
|
||
var checked = selectAllCb.checked;
|
||
body.querySelectorAll('.nzb-queue-row-cb').forEach(function (cb) {
|
||
cb.checked = checked;
|
||
var rowId = cb.getAttribute('data-id');
|
||
if (rowId) self._selectedIds[rowId] = checked;
|
||
var row = cb.closest('.nzb-queue-row');
|
||
if (row) {
|
||
if (checked) row.classList.add('nzb-row-selected');
|
||
else row.classList.remove('nzb-row-selected');
|
||
}
|
||
});
|
||
self._updateMassActionBar();
|
||
});
|
||
}
|
||
|
||
// Wire up individual row checkboxes
|
||
body.querySelectorAll('.nzb-queue-row-cb').forEach(function (cb) {
|
||
cb.addEventListener('change', function () {
|
||
var rowId = cb.getAttribute('data-id');
|
||
if (rowId) self._selectedIds[rowId] = cb.checked;
|
||
var row = cb.closest('.nzb-queue-row');
|
||
if (row) {
|
||
if (cb.checked) row.classList.add('nzb-row-selected');
|
||
else row.classList.remove('nzb-row-selected');
|
||
}
|
||
self._updateSelectAllState();
|
||
self._updateMassActionBar();
|
||
});
|
||
});
|
||
|
||
// Wire up per-row priority dropdowns
|
||
body.querySelectorAll('.nzb-priority-select').forEach(function (sel) {
|
||
sel.addEventListener('change', function () {
|
||
var rowId = sel.getAttribute('data-id');
|
||
if (rowId) self._onSinglePriorityChange(rowId, sel.value);
|
||
});
|
||
});
|
||
|
||
// Wire up mass priority dropdown
|
||
var massPriSel = document.getElementById('nzb-mass-priority-select');
|
||
if (massPriSel) {
|
||
massPriSel.addEventListener('change', function () {
|
||
if (massPriSel.value) {
|
||
self._onMassPriorityChange(massPriSel.value);
|
||
massPriSel.value = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Wire up mass delete button
|
||
var massDelBtn = document.getElementById('nzb-mass-delete-btn');
|
||
if (massDelBtn) {
|
||
massDelBtn.addEventListener('click', function () { self._onMassDelete(); });
|
||
}
|
||
|
||
var searchInput = document.getElementById('nzb-queue-search-input');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', function () {
|
||
self._queueFilter = this.value;
|
||
self._queuePage = 1;
|
||
self._renderQueue(self._lastQueue || []);
|
||
});
|
||
}
|
||
body.querySelectorAll('[data-queue-page]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var val = btn.getAttribute('data-queue-page');
|
||
if (val === 'prev') { self._queuePage = Math.max(1, self._queuePage - 1); }
|
||
else if (val === 'next') { self._queuePage++; }
|
||
else { self._queuePage = parseInt(val, 10); }
|
||
self._renderQueue(self._lastQueue || []);
|
||
});
|
||
});
|
||
|
||
// Wire up item control buttons
|
||
body.querySelectorAll('.nzb-item-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var action = btn.getAttribute('data-action');
|
||
var id = btn.getAttribute('data-id');
|
||
if (action && id) self._queueItemAction(action, id);
|
||
});
|
||
});
|
||
|
||
// Update select-all state (in case of re-render with persisted selections)
|
||
self._updateSelectAllState();
|
||
},
|
||
|
||
_queueItemAction: function (action, id) {
|
||
var self = this;
|
||
var url, method;
|
||
if (action === 'pause') {
|
||
url = './api/nzb-hunt/queue/' + id + '/pause';
|
||
method = 'POST';
|
||
} else if (action === 'resume') {
|
||
url = './api/nzb-hunt/queue/' + id + '/resume';
|
||
method = 'POST';
|
||
} else if (action === 'remove') {
|
||
url = './api/nzb-hunt/queue/' + id;
|
||
method = 'DELETE';
|
||
} else {
|
||
return;
|
||
}
|
||
fetch(url, { method: method })
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (err) {
|
||
console.error('[NzbHunt] Action error:', err);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(err.message || 'Action failed', 'error');
|
||
}
|
||
self._fetchQueueAndStatus();
|
||
});
|
||
},
|
||
|
||
_stateIcon: function (state) {
|
||
switch (state) {
|
||
case 'downloading': return 'fas fa-arrow-down nzb-icon-downloading';
|
||
case 'assembling': return 'fas fa-file-export nzb-icon-assembling';
|
||
case 'queued': return 'fas fa-clock nzb-icon-queued';
|
||
case 'paused': return 'fas fa-pause-circle nzb-icon-paused';
|
||
case 'extracting': return 'fas fa-file-archive nzb-icon-extracting';
|
||
case 'completed': return 'fas fa-check-circle nzb-icon-completed';
|
||
case 'failed': return 'fas fa-exclamation-circle nzb-icon-failed';
|
||
default: return 'fas fa-circle';
|
||
}
|
||
},
|
||
|
||
_stateLabel: function (state) {
|
||
switch (state) {
|
||
case 'downloading': return 'Downloading';
|
||
case 'assembling': return 'Assembling';
|
||
case 'queued': return 'Queued';
|
||
case 'paused': return 'Paused';
|
||
case 'extracting': return 'Extracting';
|
||
case 'completed': return 'Completed';
|
||
case 'failed': return 'Failed';
|
||
default: return state || 'Unknown';
|
||
}
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Speed Limit Popover
|
||
────────────────────────────────────────────── */
|
||
_setupSpeedLimit: function () {
|
||
var self = this;
|
||
var control = document.getElementById('nzb-speed-control');
|
||
var popover = document.getElementById('nzb-speed-popover');
|
||
if (!control || !popover) return;
|
||
|
||
// Toggle popover on click
|
||
control.addEventListener('click', function (e) {
|
||
// Don't toggle if clicking inside the popover itself
|
||
if (e.target.closest('.nzb-speed-popover')) return;
|
||
var visible = popover.style.display === 'block';
|
||
popover.style.display = visible ? 'none' : 'block';
|
||
if (!visible) {
|
||
// Highlight the current limit
|
||
self._highlightCurrentLimit();
|
||
}
|
||
});
|
||
|
||
// Close popover when clicking outside
|
||
document.addEventListener('click', function (e) {
|
||
if (!e.target.closest('#nzb-speed-control')) {
|
||
popover.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Preset speed limit buttons
|
||
popover.querySelectorAll('.nzb-speed-opt').forEach(function (btn) {
|
||
btn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
var limit = parseInt(btn.getAttribute('data-limit'), 10);
|
||
self._setSpeedLimit(limit);
|
||
popover.style.display = 'none';
|
||
});
|
||
});
|
||
|
||
// Custom speed limit
|
||
var customBtn = document.getElementById('nzb-speed-custom-btn');
|
||
var customInput = document.getElementById('nzb-speed-custom-input');
|
||
if (customBtn && customInput) {
|
||
customBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
var mbps = parseFloat(customInput.value);
|
||
if (mbps > 0) {
|
||
self._setSpeedLimit(Math.round(mbps * 1024 * 1024));
|
||
} else {
|
||
self._setSpeedLimit(0);
|
||
}
|
||
customInput.value = '';
|
||
popover.style.display = 'none';
|
||
});
|
||
customInput.addEventListener('keydown', function (e) {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter') customBtn.click();
|
||
});
|
||
customInput.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
});
|
||
}
|
||
},
|
||
|
||
_setSpeedLimit: function (bps) {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/speed-limit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ speed_limit_bps: bps })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function (data) {
|
||
if (data.success) {
|
||
var msg = bps > 0
|
||
? 'Speed limited to ' + self._formatBytes(bps) + '/s'
|
||
: 'Speed limit removed';
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(msg, 'success');
|
||
}
|
||
self._fetchQueueAndStatus();
|
||
}
|
||
})
|
||
.catch(function (err) { console.error('[NzbHunt] Speed limit error:', err); });
|
||
},
|
||
|
||
_highlightCurrentLimit: function () {
|
||
var popover = document.getElementById('nzb-speed-popover');
|
||
if (!popover) return;
|
||
|
||
// Fetch current limit
|
||
fetch('./api/nzb-hunt/speed-limit?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var current = data.speed_limit_bps || 0;
|
||
popover.querySelectorAll('.nzb-speed-opt').forEach(function (btn) {
|
||
var val = parseInt(btn.getAttribute('data-limit'), 10);
|
||
btn.classList.toggle('active', val === current);
|
||
});
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Display Preferences (server-side) – context-aware
|
||
────────────────────────────────────────────── */
|
||
_displayPrefs: {
|
||
queue: { refreshRate: 3, perPage: 20 },
|
||
history: { refreshRate: 30, perPage: 20, dateFormat: 'relative', showCategory: false, showSize: false, showIndexer: false }
|
||
},
|
||
_histPollTimer: null,
|
||
_prefsLoaded: false,
|
||
|
||
_loadDisplayPrefs: function (callback) {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/settings/display-prefs?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.queue) {
|
||
self._displayPrefs.queue.refreshRate = data.queue.refreshRate || 3;
|
||
self._displayPrefs.queue.perPage = data.queue.perPage || 20;
|
||
}
|
||
if (data.history) {
|
||
self._displayPrefs.history.refreshRate = data.history.refreshRate || 30;
|
||
self._displayPrefs.history.perPage = data.history.perPage || 20;
|
||
self._displayPrefs.history.dateFormat = data.history.dateFormat || 'relative';
|
||
self._displayPrefs.history.showCategory = !!data.history.showCategory;
|
||
self._displayPrefs.history.showSize = !!data.history.showSize;
|
||
self._displayPrefs.history.showIndexer = !!data.history.showIndexer;
|
||
}
|
||
self._histPerPage = self._displayPrefs.history.perPage;
|
||
self._queuePerPage = self._displayPrefs.queue.perPage || 20;
|
||
self._prefsLoaded = true;
|
||
console.log('[NzbHunt] Display prefs loaded from server');
|
||
if (callback) callback();
|
||
})
|
||
.catch(function (err) {
|
||
console.error('[NzbHunt] Failed to load display prefs:', err);
|
||
self._prefsLoaded = true;
|
||
if (callback) callback();
|
||
});
|
||
},
|
||
|
||
_saveDisplayPrefs: function (callback) {
|
||
var self = this;
|
||
this._histPerPage = this._displayPrefs.history.perPage;
|
||
fetch('./api/nzb-hunt/settings/display-prefs', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(this._displayPrefs)
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
console.log('[NzbHunt] Display prefs saved to server');
|
||
if (callback) callback(data);
|
||
})
|
||
.catch(function (err) {
|
||
console.error('[NzbHunt] Failed to save display prefs:', err);
|
||
if (callback) callback({ success: false });
|
||
});
|
||
},
|
||
|
||
_applyRefreshRates: function () {
|
||
var self = this;
|
||
// Queue poll timer
|
||
if (this._pollTimer) clearInterval(this._pollTimer);
|
||
var qRate = Math.max(5, this._displayPrefs.queue.refreshRate || 5) * 1000;
|
||
this._pollTimer = setInterval(function () { self._fetchQueueAndStatus(); }, qRate);
|
||
|
||
// History poll timer
|
||
if (this._histPollTimer) clearInterval(this._histPollTimer);
|
||
var hRate = (this._displayPrefs.history.refreshRate || 30) * 1000;
|
||
this._histPollTimer = setInterval(function () { self._fetchHistory(); }, hRate);
|
||
},
|
||
|
||
_openPrefsModal: function () {
|
||
var ctx = this.currentTab; // 'queue' or 'history'
|
||
var prefs = this._displayPrefs[ctx];
|
||
var titleEl = document.getElementById('nzb-prefs-title');
|
||
if (titleEl) titleEl.textContent = (ctx === 'queue' ? 'Queue' : 'History') + ' Settings';
|
||
|
||
// Show/hide history-only section
|
||
var histSec = document.getElementById('nzb-prefs-history-section');
|
||
if (histSec) histSec.style.display = (ctx === 'history') ? '' : 'none';
|
||
|
||
// Populate shared fields
|
||
var el;
|
||
el = document.getElementById('nzb-pref-refresh');
|
||
if (el) el.value = String(prefs.refreshRate || (ctx === 'queue' ? 3 : 30));
|
||
el = document.getElementById('nzb-pref-per-page');
|
||
if (el) el.value = String(prefs.perPage || 20);
|
||
|
||
// Populate history-only fields
|
||
if (ctx === 'history') {
|
||
el = document.getElementById('nzb-pref-date-format');
|
||
if (el) el.value = prefs.dateFormat || 'relative';
|
||
el = document.getElementById('nzb-pref-show-category');
|
||
if (el) el.checked = !!prefs.showCategory;
|
||
el = document.getElementById('nzb-pref-show-size');
|
||
if (el) el.checked = !!prefs.showSize;
|
||
el = document.getElementById('nzb-pref-show-indexer');
|
||
if (el) el.checked = !!prefs.showIndexer;
|
||
}
|
||
|
||
// Store context for save
|
||
this._prefsContext = ctx;
|
||
|
||
var overlay = document.getElementById('nzb-prefs-overlay');
|
||
if (overlay) overlay.style.display = 'flex';
|
||
},
|
||
|
||
_closePrefsModal: function () {
|
||
var overlay = document.getElementById('nzb-prefs-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
},
|
||
|
||
_savePrefsFromModal: function () {
|
||
var self = this;
|
||
var ctx = this._prefsContext || this.currentTab;
|
||
var prefs = this._displayPrefs[ctx];
|
||
var el;
|
||
|
||
el = document.getElementById('nzb-pref-refresh');
|
||
if (el) prefs.refreshRate = parseInt(el.value, 10) || (ctx === 'queue' ? 3 : 30);
|
||
el = document.getElementById('nzb-pref-per-page');
|
||
if (el) prefs.perPage = parseInt(el.value, 10) || 20;
|
||
|
||
if (ctx === 'history') {
|
||
el = document.getElementById('nzb-pref-date-format');
|
||
if (el) prefs.dateFormat = el.value;
|
||
el = document.getElementById('nzb-pref-show-category');
|
||
if (el) prefs.showCategory = el.checked;
|
||
el = document.getElementById('nzb-pref-show-size');
|
||
if (el) prefs.showSize = el.checked;
|
||
el = document.getElementById('nzb-pref-show-indexer');
|
||
if (el) prefs.showIndexer = el.checked;
|
||
}
|
||
|
||
this._saveDisplayPrefs(function () {
|
||
self._applyRefreshRates();
|
||
self._closePrefsModal();
|
||
|
||
if (ctx === 'history') {
|
||
self._histPage = 1;
|
||
self._renderHistory();
|
||
}
|
||
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification((ctx === 'queue' ? 'Queue' : 'History') + ' settings saved.', 'success');
|
||
}
|
||
});
|
||
},
|
||
|
||
_setupPrefsModal: function () {
|
||
var self = this;
|
||
var gearBtn = document.getElementById('nzb-display-prefs-btn');
|
||
if (gearBtn) gearBtn.addEventListener('click', function () { self._openPrefsModal(); });
|
||
|
||
var closeBtn = document.getElementById('nzb-prefs-close');
|
||
if (closeBtn) closeBtn.addEventListener('click', function () { self._closePrefsModal(); });
|
||
|
||
var saveBtn = document.getElementById('nzb-prefs-save');
|
||
if (saveBtn) saveBtn.addEventListener('click', function () { self._savePrefsFromModal(); });
|
||
|
||
var overlay = document.getElementById('nzb-prefs-overlay');
|
||
if (overlay) {
|
||
overlay.addEventListener('click', function (e) {
|
||
if (e.target === overlay) self._closePrefsModal();
|
||
});
|
||
}
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Warnings Tab
|
||
────────────────────────────────────────────── */
|
||
_updateWarnings: function (warnings) {
|
||
var tab = document.getElementById('nzb-warnings-tab');
|
||
var badge = document.getElementById('nzb-warnings-count');
|
||
var count = (warnings && warnings.length) || 0;
|
||
// Show/hide the tab
|
||
if (tab) tab.style.display = count > 0 ? '' : 'none';
|
||
if (badge) badge.textContent = count;
|
||
// If warnings panel is visible, render
|
||
this._lastWarnings = warnings || [];
|
||
if (this.currentTab === 'warnings') this._renderWarnings();
|
||
},
|
||
|
||
_renderWarnings: function () {
|
||
var body = document.getElementById('nzb-warnings-body');
|
||
if (!body) return;
|
||
var warnings = this._lastWarnings || [];
|
||
if (warnings.length === 0) {
|
||
body.innerHTML =
|
||
'<div class="nzb-queue-empty">' +
|
||
'<div class="nzb-queue-empty-icon"><i class="fas fa-check-circle" style="color: #4ade80;"></i></div>' +
|
||
'<h3>No warnings</h3>' +
|
||
'<p>Everything looks good.</p>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
var self = this;
|
||
var html = '<div class="nzb-warnings-list">';
|
||
warnings.forEach(function (w) {
|
||
var icon = w.level === 'error' ? 'fa-times-circle' : w.level === 'warning' ? 'fa-exclamation-triangle' : 'fa-info-circle';
|
||
var cls = 'nzb-warning-item nzb-warning-' + w.level;
|
||
html +=
|
||
'<div class="' + cls + '">' +
|
||
'<div class="nzb-warning-icon"><i class="fas ' + icon + '"></i></div>' +
|
||
'<div class="nzb-warning-body">' +
|
||
'<div class="nzb-warning-title">' + self._escHtml(w.title) + '</div>' +
|
||
'<div class="nzb-warning-msg">' + self._escHtml(w.message) + '</div>' +
|
||
'<div class="nzb-warning-time">' + self._timeAgo(w.time) + '</div>' +
|
||
'</div>' +
|
||
'<button class="nzb-warning-dismiss" data-warn-id="' + self._escHtml(w.id) + '" title="Dismiss"><i class="fas fa-times"></i></button>' +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
body.innerHTML = html;
|
||
// Bind dismiss buttons
|
||
body.querySelectorAll('.nzb-warning-dismiss').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
self._dismissWarning(btn.getAttribute('data-warn-id'));
|
||
});
|
||
});
|
||
},
|
||
|
||
_dismissWarning: function (warnId) {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/warnings/dismiss', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: warnId })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (e) { console.error('[NzbHunt] Dismiss warning:', e); self._fetchQueueAndStatus(); });
|
||
},
|
||
|
||
_dismissAllWarnings: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/warnings/dismiss', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: '__all__' })
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchQueueAndStatus(); })
|
||
.catch(function (e) { console.error('[NzbHunt] Dismiss all warnings:', e); self._fetchQueueAndStatus(); });
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
History Rendering
|
||
────────────────────────────────────────────── */
|
||
_histPage: 1,
|
||
_histPerPage: 20,
|
||
_histAll: [],
|
||
_histFilter: '',
|
||
_queuePage: 1,
|
||
_queuePerPage: 20,
|
||
_queueFilter: '',
|
||
_lastQueue: [],
|
||
|
||
_fetchHistory: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/history?limit=5000&t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var hist = data.history || [];
|
||
// Sort newest first
|
||
hist.sort(function (a, b) {
|
||
var ta = new Date(a.completed_at || a.added_at || 0).getTime();
|
||
var tb = new Date(b.completed_at || b.added_at || 0).getTime();
|
||
return tb - ta;
|
||
});
|
||
self._histAll = hist;
|
||
self._histPage = 1;
|
||
self._renderHistory();
|
||
})
|
||
.catch(function (err) { console.error('[NzbHunt] History fetch error:', err); });
|
||
},
|
||
|
||
_timeAgo: function (dateStr) {
|
||
if (!dateStr) return '—';
|
||
var now = Date.now();
|
||
var then = new Date(dateStr).getTime();
|
||
var diff = Math.max(0, now - then);
|
||
var sec = Math.floor(diff / 1000);
|
||
if (sec < 60) return 'just now';
|
||
var min = Math.floor(sec / 60);
|
||
if (min < 60) return min + (min === 1 ? ' minute ago' : ' minutes ago');
|
||
var hr = Math.floor(min / 60);
|
||
if (hr < 24) return hr + (hr === 1 ? ' hour ago' : ' hours ago');
|
||
var days = Math.floor(hr / 24);
|
||
if (days < 30) return days + (days === 1 ? ' day ago' : ' days ago');
|
||
var months = Math.floor(days / 30);
|
||
return months + (months === 1 ? ' month ago' : ' months ago');
|
||
},
|
||
|
||
_renderHistory: function () {
|
||
var body = document.getElementById('nzb-history-body');
|
||
if (!body) return;
|
||
|
||
var all = this._histAll;
|
||
var badge = document.getElementById('nzb-history-count');
|
||
if (badge) badge.textContent = all.length;
|
||
|
||
// Filter
|
||
var filter = this._histFilter.toLowerCase();
|
||
var filtered = filter
|
||
? all.filter(function (h) { return (h.name || '').toLowerCase().indexOf(filter) !== -1; })
|
||
: all;
|
||
|
||
// Empty state
|
||
if (!filtered.length) {
|
||
body.innerHTML =
|
||
'<div class="nzb-queue-empty">' +
|
||
'<div class="nzb-queue-empty-icon"><i class="fas fa-history"></i></div>' +
|
||
'<h3>No history yet</h3>' +
|
||
'<p>Completed downloads will be logged here.</p>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// Pagination
|
||
var perPage = this._histPerPage;
|
||
var totalPages = Math.ceil(filtered.length / perPage);
|
||
if (this._histPage > totalPages) this._histPage = totalPages;
|
||
var start = (this._histPage - 1) * perPage;
|
||
var page = filtered.slice(start, start + perPage);
|
||
|
||
// Bandwidth stats
|
||
var totalBytes = 0;
|
||
all.forEach(function (h) { totalBytes += (h.total_bytes || h.downloaded_bytes || 0); });
|
||
|
||
var self = this;
|
||
var prefs = this._displayPrefs.history;
|
||
// Same column structure as Queue: NAME | CATEGORY | SIZE | RESULT | AGE | actions
|
||
var html =
|
||
'<table class="nzb-queue-table nzb-history-table">' +
|
||
'<thead><tr>' +
|
||
'<th class="nzb-col-name">Name</th>' +
|
||
'<th class="nzb-col-cat">Category</th>' +
|
||
'<th class="nzb-col-size">Size</th>' +
|
||
'<th class="nzb-col-status">Result</th>' +
|
||
'<th class="nzb-col-eta">Age</th>' +
|
||
'<th class="nzb-col-actions"></th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
page.forEach(function (item) {
|
||
var isSuccess = item.state === 'completed';
|
||
var name = self._escHtml(item.name || 'Unknown');
|
||
var catLabel = item.category ? self._escHtml(String(item.category)) : '—';
|
||
var size = self._formatBytes(item.total_bytes || item.downloaded_bytes || 0);
|
||
var dateVal = item.completed_at || item.added_at;
|
||
var age = prefs.dateFormat === 'absolute'
|
||
? (dateVal ? new Date(dateVal).toLocaleString() : '—')
|
||
: self._timeAgo(dateVal);
|
||
|
||
// Result — label on top, detail below (matches queue layout)
|
||
var resultHtml;
|
||
if (isSuccess) {
|
||
resultHtml =
|
||
'<span class="nzb-status-label"><i class="fas fa-check-circle nzb-icon-completed"></i> Completed</span>';
|
||
} else {
|
||
var errMsg = item.error_message || '';
|
||
var failLabel = 'Failed';
|
||
var failDetail = '';
|
||
|
||
if (/missing article/i.test(errMsg) || /DMCA/i.test(errMsg)) {
|
||
failLabel = 'Aborted';
|
||
failDetail = 'Missing articles';
|
||
} else if (/extraction failed/i.test(errMsg)) {
|
||
failLabel = 'Failed';
|
||
failDetail = 'Extraction error';
|
||
} else if (/timed out/i.test(errMsg)) {
|
||
failLabel = 'Failed';
|
||
failDetail = 'Timed out';
|
||
} else if (errMsg) {
|
||
failDetail = errMsg.length > 30
|
||
? self._escHtml(errMsg.substring(0, 28)) + '…'
|
||
: self._escHtml(errMsg);
|
||
}
|
||
|
||
resultHtml =
|
||
'<span class="nzb-status-label"><i class="fas fa-times-circle nzb-icon-failed"></i> ' + failLabel + '</span>';
|
||
if (failDetail) {
|
||
resultHtml += '<span class="nzb-status-sub nzb-status-msg">' + failDetail + '</span>';
|
||
}
|
||
if (errMsg) {
|
||
resultHtml = '<span class="nzb-status-with-tooltip" title="">' + resultHtml +
|
||
'<div class="nzb-cell-tooltip">' + self._escHtml(errMsg) + '</div></span>';
|
||
}
|
||
}
|
||
|
||
var nzbId = item.nzo_id || item.id || '';
|
||
|
||
html += '<tr class="nzb-queue-row ' + (isSuccess ? 'nzb-item-completed' : 'nzb-item-failed') + '">' +
|
||
'<td class="nzb-col-name" data-label="Name" title="' + name + '"><span class="nzb-cell-name">' + name + '</span></td>' +
|
||
'<td class="nzb-col-cat" data-label="Category"><span class="nzb-cell-cat">' + catLabel + '</span></td>' +
|
||
'<td class="nzb-col-size" data-label="Size">' + size + '</td>' +
|
||
'<td class="nzb-col-status" data-label="Result">' + resultHtml + '</td>' +
|
||
'<td class="nzb-col-eta" data-label="Age">' + age + '</td>' +
|
||
'<td class="nzb-col-actions" data-label="">' +
|
||
'<button type="button" class="nzb-item-btn nzb-item-btn-danger nzb-hist-delete-btn" data-nzb-id="' + nzbId + '" title="Delete"><i class="fas fa-trash-alt"></i></button>' +
|
||
'</td></tr>';
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
html = '<div class="nzb-table-scroll">' + html + '</div>';
|
||
html += '<div class="nzb-history-footer">';
|
||
html += '<div class="nzb-hist-search"><i class="fas fa-search"></i><input type="text" id="nzb-hist-search-input" placeholder="Search" value="' + self._escHtml(this._histFilter) + '" /></div>';
|
||
html += '<div class="nzb-hist-pagination">';
|
||
if (totalPages > 1) {
|
||
html += '<button data-hist-page="prev" ' + (this._histPage <= 1 ? 'disabled' : '') + '>«</button>';
|
||
// Show page numbers with ellipsis
|
||
var pages = self._paginationRange(this._histPage, totalPages);
|
||
for (var i = 0; i < pages.length; i++) {
|
||
if (pages[i] === '…') {
|
||
html += '<span>…</span>';
|
||
} else {
|
||
html += '<button data-hist-page="' + pages[i] + '" ' + (pages[i] === this._histPage ? 'class="active"' : '') + '>' + pages[i] + '</button>';
|
||
}
|
||
}
|
||
html += '<button data-hist-page="next" ' + (this._histPage >= totalPages ? 'disabled' : '') + '>»</button>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="nzb-hist-stats"><span><i class="fas fa-download"></i>' + self._formatBytes(totalBytes) + ' Total</span><span>' + filtered.length + ' items</span></div>';
|
||
html += '</div>';
|
||
|
||
body.innerHTML = html;
|
||
|
||
// Wire up search
|
||
var searchInput = document.getElementById('nzb-hist-search-input');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', function () {
|
||
self._histFilter = this.value;
|
||
self._histPage = 1;
|
||
self._renderHistory();
|
||
});
|
||
}
|
||
|
||
// Wire up pagination
|
||
body.querySelectorAll('[data-hist-page]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var val = btn.getAttribute('data-hist-page');
|
||
if (val === 'prev') { self._histPage = Math.max(1, self._histPage - 1); }
|
||
else if (val === 'next') { self._histPage++; }
|
||
else { self._histPage = parseInt(val, 10); }
|
||
self._renderHistory();
|
||
});
|
||
});
|
||
|
||
// Wire up per-row delete
|
||
body.querySelectorAll('.nzb-hist-delete-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var id = btn.getAttribute('data-nzb-id');
|
||
if (!id) return;
|
||
self._deleteHistoryItem(id);
|
||
});
|
||
});
|
||
},
|
||
|
||
_paginationRange: function (current, total) {
|
||
if (total <= 7) {
|
||
var arr = [];
|
||
for (var i = 1; i <= total; i++) arr.push(i);
|
||
return arr;
|
||
}
|
||
var pages = [1];
|
||
if (current > 3) pages.push('…');
|
||
for (var p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) {
|
||
pages.push(p);
|
||
}
|
||
if (current < total - 2) pages.push('…');
|
||
pages.push(total);
|
||
return pages;
|
||
},
|
||
|
||
_deleteHistoryItem: function (nzbId) {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/history/' + encodeURIComponent(nzbId), { method: 'DELETE' })
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchHistory(); })
|
||
.catch(function (err) {
|
||
console.error('[NzbHunt] Delete history item error:', err);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(err.message || 'Delete failed', 'error');
|
||
}
|
||
self._fetchHistory();
|
||
});
|
||
},
|
||
|
||
_clearHistory: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/history', { method: 'DELETE' })
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function () { self._fetchHistory(); })
|
||
.catch(function (err) {
|
||
console.error('[NzbHunt] Clear history error:', err);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(err.message || 'Clear history failed', 'error');
|
||
}
|
||
self._fetchHistory();
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Utility helpers
|
||
────────────────────────────────────────────── */
|
||
_formatBytes: function (bytes) {
|
||
if (!bytes || bytes === 0) return '0 B';
|
||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
var i = 0;
|
||
var b = bytes;
|
||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||
return b.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||
},
|
||
|
||
_formatEta: function (seconds) {
|
||
if (!seconds || seconds <= 0) return '--:--';
|
||
var h = Math.floor(seconds / 3600);
|
||
var m = Math.floor((seconds % 3600) / 60);
|
||
var s = seconds % 60;
|
||
if (h > 0) return h + 'h ' + (m < 10 ? '0' : '') + m + 'm';
|
||
return m + 'm ' + (s < 10 ? '0' : '') + s + 's';
|
||
},
|
||
|
||
_escHtml: function (str) {
|
||
var d = document.createElement('div');
|
||
d.textContent = str;
|
||
return d.innerHTML;
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Stop polling (called when leaving NZB home)
|
||
────────────────────────────────────────────── */
|
||
stopPolling: function () {
|
||
if (this._pollTimer) {
|
||
clearInterval(this._pollTimer);
|
||
this._pollTimer = null;
|
||
}
|
||
if (this._histPollTimer) {
|
||
clearInterval(this._histPollTimer);
|
||
this._histPollTimer = null;
|
||
}
|
||
},
|
||
};
|
||
|
||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||
function _esc(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s || '';
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function _fmtBytes(b) {
|
||
if (!b || b <= 0) return '0 B';
|
||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||
}
|
||
|
||
function _capFirst(s) {
|
||
if (!s) return '';
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
// Initialize on DOM ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', function () { /* wait for section switch */ });
|
||
}
|
||
})();
|
||
|
||
|
||
/* === modules/features/nzb-hunt-settings.js === */
|
||
/**
|
||
* NZB Hunt Settings - Folders, Servers, Categories, Processing, Advanced
|
||
* Extends window.NzbHunt defined in nzb-hunt.js
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ── Helpers (shared with nzb-hunt.js, duplicated for IIFE scope) ── */
|
||
function _esc(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s || '';
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function _fmtBytes(b) {
|
||
if (!b || b <= 0) return '0 B';
|
||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||
}
|
||
|
||
function _capFirst(s) {
|
||
if (!s) return '';
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
function _parseJsonOrThrow(r) {
|
||
return r.json().then(function (data) {
|
||
if (!r.ok) throw new Error(data && (data.error || data.message) || 'Request failed');
|
||
return data;
|
||
});
|
||
}
|
||
|
||
Object.assign(window.NzbHunt, {
|
||
initSettings: function () {
|
||
this._setupSettingsTabs();
|
||
this._setupFolderBrowse();
|
||
this._setupServerGrid();
|
||
this._setupServerEditor();
|
||
this._setupBrowseModal();
|
||
this._setupCategoryGrid();
|
||
this._setupCategoryModal();
|
||
this._setupAdvanced();
|
||
this._loadFolders();
|
||
this._loadServers();
|
||
this._loadCategories();
|
||
this._loadAdvanced();
|
||
this._loadProcessing();
|
||
this._updateNzbServersSetupBanner();
|
||
console.log('[NzbHunt] Settings initialized');
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
NZB Home tabs (Queue / History)
|
||
────────────────────────────────────────────── */
|
||
setupTabs: function () {
|
||
var self = this;
|
||
var tabs = document.querySelectorAll('#nzb-hunt-section .nzb-tab');
|
||
tabs.forEach(function (tab) {
|
||
tab.addEventListener('click', function () {
|
||
var target = tab.getAttribute('data-tab');
|
||
if (target) self.showTab(target);
|
||
});
|
||
});
|
||
},
|
||
|
||
showTab: function (tab) {
|
||
this.currentTab = tab;
|
||
document.querySelectorAll('#nzb-hunt-section .nzb-tab').forEach(function (t) {
|
||
t.classList.toggle('active', t.getAttribute('data-tab') === tab);
|
||
});
|
||
document.querySelectorAll('#nzb-hunt-section .nzb-tab-panel').forEach(function (p) {
|
||
p.style.display = p.getAttribute('data-panel') === tab ? 'block' : 'none';
|
||
});
|
||
if (tab === 'history') { this._fetchHistory(); }
|
||
if (tab === 'warnings') { this._renderWarnings(); }
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Settings sub-tabs (Folders / Servers)
|
||
────────────────────────────────────────────── */
|
||
_setupSettingsTabs: function () {
|
||
// Tabs are now in the sidebar, handled by app.js
|
||
},
|
||
|
||
_showSettingsTab: function (tab) {
|
||
document.querySelectorAll('#nzb-hunt-settings-section .nzb-settings-panel').forEach(function (p) {
|
||
p.style.display = p.getAttribute('data-settings-panel') === tab ? 'block' : 'none';
|
||
});
|
||
var bc = document.getElementById('nzb-hunt-settings-breadcrumb-current');
|
||
if (bc) {
|
||
var labels = { folders: 'Folders', servers: 'Servers', advanced: 'Advanced' };
|
||
bc.textContent = labels[tab] || tab;
|
||
}
|
||
// Toggle header save button vs sponsor based on tab
|
||
var headerSave = document.getElementById('nzb-save-advanced-header');
|
||
var sponsorSlot = document.getElementById('nzb-hunt-settings-sponsor-slot');
|
||
if (tab === 'advanced') {
|
||
if (headerSave) headerSave.style.display = '';
|
||
if (sponsorSlot) sponsorSlot.style.display = 'none';
|
||
} else {
|
||
if (headerSave) headerSave.style.display = 'none';
|
||
if (sponsorSlot) sponsorSlot.style.display = '';
|
||
}
|
||
// Show/hide setup wizard continue banner on servers tab
|
||
if (tab === 'servers') {
|
||
this._updateNzbServersSetupBanner();
|
||
}
|
||
},
|
||
|
||
_fromSetupWizard: false,
|
||
|
||
_updateNzbServersSetupBanner: function () {
|
||
var banner = document.getElementById('nzb-servers-setup-wizard-continue-banner');
|
||
if (!banner) return;
|
||
// Show if user navigated here from the setup wizard.
|
||
// Don't remove the flag — it needs to persist across re-renders during the wizard flow.
|
||
var fromWizard = false;
|
||
try { fromWizard = sessionStorage.getItem('setup-wizard-active-nav') === '1'; } catch (e) {}
|
||
if (fromWizard) {
|
||
this._fromSetupWizard = true;
|
||
}
|
||
banner.style.display = (fromWizard || this._fromSetupWizard) ? 'flex' : 'none';
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Folders – load / save / browse (combined with categories)
|
||
────────────────────────────────────────────── */
|
||
_loadFolders: function () {
|
||
fetch('./api/nzb-hunt/settings/folders?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var tf = document.getElementById('nzb-temp-folder');
|
||
if (tf && data.temp_folder !== undefined) tf.value = data.temp_folder;
|
||
})
|
||
.catch(function () { /* use defaults */ });
|
||
},
|
||
|
||
_saveFolders: function () {
|
||
var payload = {
|
||
temp_folder: (document.getElementById('nzb-temp-folder') || {}).value || '/downloads/incomplete'
|
||
};
|
||
fetch('./api/nzb-hunt/settings/folders', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(function (r) { return _parseJsonOrThrow(r); })
|
||
.then(function (data) {
|
||
if (data.success) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Temporary folder saved.', 'success');
|
||
}
|
||
if (window.NzbHunt) {
|
||
window.NzbHunt._loadCategories();
|
||
}
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to save folder.', 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
_setupFolderBrowse: function () {
|
||
var self = this;
|
||
var browseTemp = document.getElementById('nzb-browse-temp-folder');
|
||
if (browseTemp) {
|
||
browseTemp.addEventListener('click', function () {
|
||
self._openBrowseModal(document.getElementById('nzb-temp-folder'));
|
||
});
|
||
}
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
File Browser Modal
|
||
────────────────────────────────────────────── */
|
||
_browseTarget: null,
|
||
|
||
_setupBrowseModal: function () {
|
||
var self = this;
|
||
var backdrop = document.getElementById('nzb-browse-backdrop');
|
||
var closeBtn = document.getElementById('nzb-browse-close');
|
||
var cancelBtn = document.getElementById('nzb-browse-cancel');
|
||
var okBtn = document.getElementById('nzb-browse-ok');
|
||
var upBtn = document.getElementById('nzb-browse-up');
|
||
|
||
if (backdrop) backdrop.addEventListener('click', function () { self._closeBrowseModal(); });
|
||
if (closeBtn) closeBtn.addEventListener('click', function () { self._closeBrowseModal(); });
|
||
if (cancelBtn) cancelBtn.addEventListener('click', function () { self._closeBrowseModal(); });
|
||
if (okBtn) okBtn.addEventListener('click', function () { self._confirmBrowse(); });
|
||
if (upBtn) upBtn.addEventListener('click', function () { self._browseParent(); });
|
||
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
if (pathInput) {
|
||
pathInput.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); self._loadBrowsePath(pathInput.value); }
|
||
});
|
||
}
|
||
|
||
// New folder button + inline create
|
||
var newFolderBtn = document.getElementById('nzb-browse-new-folder');
|
||
if (newFolderBtn) newFolderBtn.addEventListener('click', function () { self._browseShowCreateFolder(); });
|
||
var createConfirm = document.getElementById('nzb-browse-new-folder-confirm');
|
||
var createCancel = document.getElementById('nzb-browse-new-folder-cancel');
|
||
var createInput = document.getElementById('nzb-browse-new-folder-input');
|
||
if (createConfirm) createConfirm.addEventListener('click', function () { self._browseDoCreateFolder(); });
|
||
if (createCancel) createCancel.addEventListener('click', function () { self._browseHideCreateFolder(); });
|
||
if (createInput) createInput.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); self._browseDoCreateFolder(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); self._browseHideCreateFolder(); }
|
||
});
|
||
|
||
// Delete confirm buttons
|
||
var deleteYes = document.getElementById('nzb-browse-delete-yes');
|
||
var deleteNo = document.getElementById('nzb-browse-delete-no');
|
||
if (deleteYes) deleteYes.addEventListener('click', function () { self._browseDoDeleteFolder(); });
|
||
if (deleteNo) deleteNo.addEventListener('click', function () { self._browseHideDeleteFolder(); });
|
||
|
||
// Escape key to close
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') {
|
||
var modal = document.getElementById('nzb-browse-modal');
|
||
if (modal && modal.style.display === 'flex') self._closeBrowseModal();
|
||
}
|
||
});
|
||
},
|
||
|
||
_openBrowseModal: function (targetInput) {
|
||
this._browseTarget = targetInput;
|
||
var modal = document.getElementById('nzb-browse-modal');
|
||
if (!modal) return;
|
||
if (modal.parentElement !== document.body) document.body.appendChild(modal);
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
var startPath = (targetInput && targetInput.value) ? targetInput.value : '/';
|
||
if (pathInput) pathInput.value = startPath;
|
||
modal.style.display = 'flex';
|
||
this._loadBrowsePath(startPath);
|
||
},
|
||
|
||
_closeBrowseModal: function () {
|
||
var modal = document.getElementById('nzb-browse-modal');
|
||
if (modal) modal.style.display = 'none';
|
||
},
|
||
|
||
_confirmBrowse: function () {
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
if (this._browseTarget && pathInput) {
|
||
this._browseTarget.value = pathInput.value;
|
||
if (this._browseTarget.id === 'nzb-temp-folder') {
|
||
this._saveFolders();
|
||
}
|
||
}
|
||
this._closeBrowseModal();
|
||
},
|
||
|
||
_browseParent: function () {
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
if (!pathInput) return;
|
||
var cur = pathInput.value || '/';
|
||
if (cur === '/') return;
|
||
var parts = cur.replace(/\/+$/, '').split('/');
|
||
parts.pop();
|
||
var parent = parts.join('/') || '/';
|
||
pathInput.value = parent;
|
||
this._loadBrowsePath(parent);
|
||
},
|
||
|
||
/* ── Browse: Create folder ── */
|
||
_browseShowCreateFolder: function () {
|
||
var row = document.getElementById('nzb-browse-new-folder-row');
|
||
var input = document.getElementById('nzb-browse-new-folder-input');
|
||
var delRow = document.getElementById('nzb-browse-delete-confirm-row');
|
||
if (delRow) delRow.style.display = 'none';
|
||
if (!row || !input) return;
|
||
row.style.display = 'flex';
|
||
input.value = '';
|
||
setTimeout(function () { input.focus(); }, 50);
|
||
},
|
||
|
||
_browseHideCreateFolder: function () {
|
||
var row = document.getElementById('nzb-browse-new-folder-row');
|
||
if (row) row.style.display = 'none';
|
||
},
|
||
|
||
_browseDoCreateFolder: function () {
|
||
var input = document.getElementById('nzb-browse-new-folder-input');
|
||
var row = document.getElementById('nzb-browse-new-folder-row');
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
var name = (input && input.value || '').trim();
|
||
if (!name) { if (input) input.focus(); return; }
|
||
var parent = (pathInput && pathInput.value || '').trim() || '/';
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/browse/create', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ parent_path: parent, name: name })
|
||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||
if (data.success) {
|
||
if (row) row.style.display = 'none';
|
||
self._loadBrowsePath(parent);
|
||
} else {
|
||
if (input) { input.style.borderColor = '#f87171'; input.focus(); }
|
||
}
|
||
}).catch(function () { if (input) { input.style.borderColor = '#f87171'; input.focus(); } });
|
||
},
|
||
|
||
/* ── Browse: Rename folder ── */
|
||
_browseRenameFolder: function (path, currentName, el) {
|
||
var main = el && el.querySelector('.nzb-browse-item-main');
|
||
if (!main) return;
|
||
var origHTML = main.innerHTML;
|
||
main.innerHTML = '<i class="fas fa-folder" style="color:#818cf8;flex-shrink:0;"></i>' +
|
||
'<input type="text" class="nzb-browse-item-rename-input" value="' + (currentName || '').replace(/"/g, '"') + '" />' +
|
||
'<button type="button" class="nzb-browse-inline-ok nzb-rename-confirm"><i class="fas fa-check"></i></button>' +
|
||
'<button type="button" class="nzb-browse-inline-cancel nzb-rename-cancel"><i class="fas fa-times"></i></button>';
|
||
var inp = main.querySelector('input');
|
||
if (inp) { inp.focus(); inp.select(); }
|
||
main.onclick = null;
|
||
var self = this;
|
||
function doRename() {
|
||
var name = (inp && inp.value || '').trim();
|
||
if (!name || name === currentName) { revert(); return; }
|
||
fetch('./api/nzb-hunt/browse/rename', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, new_name: name })
|
||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||
if (data.success) {
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
var parent = path.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||
self._loadBrowsePath(parent || (pathInput && pathInput.value) || '/');
|
||
} else {
|
||
if (inp) { inp.style.borderColor = '#f87171'; inp.focus(); }
|
||
}
|
||
}).catch(function () { revert(); });
|
||
}
|
||
function revert() {
|
||
main.innerHTML = origHTML;
|
||
self._rebindBrowseItem(el);
|
||
}
|
||
main.querySelector('.nzb-rename-confirm').onclick = function (e) { e.stopPropagation(); doRename(); };
|
||
main.querySelector('.nzb-rename-cancel').onclick = function (e) { e.stopPropagation(); revert(); };
|
||
if (inp) inp.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); doRename(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); revert(); }
|
||
});
|
||
},
|
||
|
||
/* ── Browse: Delete folder ── */
|
||
_pendingDeletePath: null,
|
||
|
||
_browseShowDeleteFolder: function (path, name) {
|
||
var row = document.getElementById('nzb-browse-delete-confirm-row');
|
||
var nameEl = document.getElementById('nzb-browse-delete-name');
|
||
var newRow = document.getElementById('nzb-browse-new-folder-row');
|
||
if (newRow) newRow.style.display = 'none';
|
||
if (!row) return;
|
||
row.style.display = 'flex';
|
||
if (nameEl) nameEl.textContent = 'Delete "' + (name || path) + '"?';
|
||
this._pendingDeletePath = path;
|
||
},
|
||
|
||
_browseHideDeleteFolder: function () {
|
||
var row = document.getElementById('nzb-browse-delete-confirm-row');
|
||
if (row) row.style.display = 'none';
|
||
this._pendingDeletePath = null;
|
||
},
|
||
|
||
_browseDoDeleteFolder: function () {
|
||
var path = this._pendingDeletePath;
|
||
var row = document.getElementById('nzb-browse-delete-confirm-row');
|
||
if (!path) return;
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/browse/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path })
|
||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||
if (data.success) {
|
||
if (row) row.style.display = 'none';
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
var parent = path.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||
self._loadBrowsePath(parent);
|
||
}
|
||
}).catch(function () {});
|
||
},
|
||
|
||
/* ── Browse: rebind item click handlers after rename revert ── */
|
||
_rebindBrowseItem: function (el) {
|
||
var self = this;
|
||
var main = el.querySelector('.nzb-browse-item-main');
|
||
if (main) {
|
||
main.onclick = function () {
|
||
var p = el.getAttribute('data-path') || '';
|
||
if (p) self._loadBrowsePath(p);
|
||
};
|
||
}
|
||
el.querySelectorAll('.nzb-browse-item-btn').forEach(function (btn) {
|
||
var action = btn.getAttribute('data-action');
|
||
if (action === 'rename') {
|
||
btn.onclick = function (e) {
|
||
e.stopPropagation();
|
||
self._browseRenameFolder(el.getAttribute('data-path'), el.getAttribute('data-name'), el);
|
||
};
|
||
} else if (action === 'delete') {
|
||
btn.onclick = function (e) {
|
||
e.stopPropagation();
|
||
self._browseShowDeleteFolder(el.getAttribute('data-path'), el.getAttribute('data-name'));
|
||
};
|
||
}
|
||
});
|
||
},
|
||
|
||
_loadBrowsePath: function (path) {
|
||
var list = document.getElementById('nzb-browse-list');
|
||
var pathInput = document.getElementById('nzb-browse-path-input');
|
||
var upBtn = document.getElementById('nzb-browse-up');
|
||
if (!list) return;
|
||
|
||
// Hide inline rows on navigate
|
||
var newRow = document.getElementById('nzb-browse-new-folder-row');
|
||
var delRow = document.getElementById('nzb-browse-delete-confirm-row');
|
||
if (newRow) newRow.style.display = 'none';
|
||
if (delRow) delRow.style.display = 'none';
|
||
|
||
list.innerHTML = '<div style="padding: 20px; text-align: center; color: #94a3b8;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/browse?path=' + encodeURIComponent(path) + '&t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (pathInput) pathInput.value = data.path || path;
|
||
if (upBtn) {
|
||
var currentPath = (pathInput && pathInput.value || '').trim() || '/';
|
||
var parent = currentPath.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||
upBtn.disabled = (parent === currentPath || currentPath === '/' || currentPath === '');
|
||
}
|
||
var dirs = data.directories || [];
|
||
if (dirs.length === 0) {
|
||
list.innerHTML = '<div style="padding: 20px; text-align: center; color: #64748b;">No subdirectories</div>';
|
||
return;
|
||
}
|
||
var html = '';
|
||
for (var i = 0; i < dirs.length; i++) {
|
||
var d = dirs[i];
|
||
var rawName = d.name || '';
|
||
var name = rawName.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
var p = (d.path || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
var nameAttr = rawName.replace(/"/g, '"');
|
||
html += '<div class="nzb-browse-item" data-path="' + p + '" data-name="' + nameAttr + '" title="' + p + '">' +
|
||
'<span class="nzb-browse-item-main">' +
|
||
'<i class="fas fa-folder"></i>' +
|
||
'<span style="font-family: monospace; font-size: 0.9rem; word-break: break-all;">' + name + '</span>' +
|
||
'</span>' +
|
||
'<span class="nzb-browse-item-actions">' +
|
||
'<button type="button" class="nzb-browse-item-btn" data-action="rename" title="Rename"><i class="fas fa-pen"></i></button>' +
|
||
'<button type="button" class="nzb-browse-item-btn" data-action="delete" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||
'</span></div>';
|
||
}
|
||
list.innerHTML = html;
|
||
list.querySelectorAll('.nzb-browse-item').forEach(function (el) {
|
||
self._rebindBrowseItem(el);
|
||
});
|
||
})
|
||
.catch(function () {
|
||
list.innerHTML = '<div style="padding: 20px; text-align: center; color: #f87171;">Failed to browse directory</div>';
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Servers – CRUD + card rendering
|
||
────────────────────────────────────────────── */
|
||
_setupServerGrid: function () {
|
||
var self = this;
|
||
var addCard = document.getElementById('nzb-add-server-card');
|
||
if (addCard) {
|
||
addCard.addEventListener('click', function () {
|
||
self._editIndex = null;
|
||
self._navigateToServerEditor(null);
|
||
});
|
||
}
|
||
},
|
||
|
||
_loadServers: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/servers?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
self._servers = data.servers || [];
|
||
self._renderServerCards();
|
||
})
|
||
.catch(function () { self._servers = []; self._renderServerCards(); });
|
||
},
|
||
|
||
_renderServerCards: function () {
|
||
var grid = document.getElementById('nzb-server-grid');
|
||
if (!grid) return;
|
||
|
||
// Remove existing server cards (keep the add card)
|
||
var addCard = document.getElementById('nzb-add-server-card');
|
||
grid.innerHTML = '';
|
||
|
||
var self = this;
|
||
this._servers.forEach(function (srv, idx) {
|
||
var card = document.createElement('div');
|
||
card.className = 'nzb-server-card';
|
||
var statusDotId = 'nzb-server-status-' + idx;
|
||
var statusTextId = 'nzb-server-status-text-' + idx;
|
||
card.innerHTML =
|
||
'<div class="nzb-server-card-header">' +
|
||
'<div class="nzb-server-card-name">' +
|
||
'<span class="nzb-server-status-dot status-checking" id="' + statusDotId + '" title="Checking..."></span>' +
|
||
'<i class="fas fa-server"></i> <span>' + _esc(srv.name || 'Server') + '</span>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-card-badges">' +
|
||
'<span class="nzb-badge nzb-badge-priority">P: ' + (srv.priority !== undefined ? srv.priority : 0) + '</span>' +
|
||
(srv.ssl ? '<span class="nzb-badge nzb-badge-ssl">SSL</span>' : '') +
|
||
'<span class="nzb-badge ' + (srv.enabled !== false ? 'nzb-badge-enabled' : 'nzb-badge-disabled') + '">' + (srv.enabled !== false ? 'ON' : 'OFF') + '</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-card-body">' +
|
||
'<div class="nzb-server-detail"><i class="fas fa-globe"></i> <span>' + _esc(srv.host || '') + ':' + (srv.port || 563) + '</span></div>' +
|
||
'<div class="nzb-server-detail"><i class="fas fa-plug"></i> <span>' + (srv.connections || 8) + ' connections</span></div>' +
|
||
(srv.username ? '<div class="nzb-server-detail"><i class="fas fa-user"></i> <span>' + _esc(srv.username) + '</span></div>' : '') +
|
||
(srv.password_masked ? '<div class="nzb-server-detail"><i class="fas fa-key"></i> <span style="font-family: monospace; letter-spacing: 1px;">' + _esc(srv.password_masked) + '</span></div>' : '') +
|
||
'<div class="nzb-server-status-line" id="' + statusTextId + '">' +
|
||
'<i class="fas fa-circle-notch fa-spin" style="font-size: 11px; color: #6366f1;"></i> <span style="font-size: 12px; color: #94a3b8;">Checking connection...</span>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-bandwidth">' +
|
||
'<div class="nzb-server-bandwidth-grid">' +
|
||
'<span class="nzb-bw-cell"><span class="nzb-bw-label">1h</span><span class="nzb-bw-value">' + _fmtBytes(srv.bandwidth_1h || 0) + '</span></span>' +
|
||
'<span class="nzb-bw-cell"><span class="nzb-bw-label">24h</span><span class="nzb-bw-value">' + _fmtBytes(srv.bandwidth_24h || 0) + '</span></span>' +
|
||
'<span class="nzb-bw-cell"><span class="nzb-bw-label">30d</span><span class="nzb-bw-value">' + _fmtBytes(srv.bandwidth_30d || 0) + '</span></span>' +
|
||
'<span class="nzb-bw-cell"><span class="nzb-bw-label">Total</span><span class="nzb-bw-value">' + _fmtBytes(srv.bandwidth_total || srv.bandwidth_used || 0) + '</span></span>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-bandwidth-bar"><div class="nzb-server-bandwidth-fill" style="width: ' + Math.min(100, (srv.bandwidth_pct || 0)) + '%;"></div></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-card-footer">' +
|
||
'<button class="nzb-btn" data-action="edit" data-idx="' + idx + '"><i class="fas fa-pen"></i> Edit</button>' +
|
||
'<button class="nzb-btn nzb-btn-danger" data-action="delete" data-idx="' + idx + '"><i class="fas fa-trash"></i> Delete</button>' +
|
||
'</div>' +
|
||
'<div class="nzb-server-card-footer nzb-server-card-footer-secondary">' +
|
||
'<button class="nzb-btn nzb-btn-subtle" data-action="reset-stats" data-idx="' + idx + '"><i class="fas fa-undo"></i> Reset Stats</button>' +
|
||
'</div>';
|
||
|
||
card.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
var action = btn.getAttribute('data-action');
|
||
var i = parseInt(btn.getAttribute('data-idx'), 10);
|
||
if (action === 'edit') {
|
||
self._editIndex = i;
|
||
self._navigateToServerEditor(self._servers[i]);
|
||
} else if (action === 'reset-stats') {
|
||
var name = (self._servers[i] || {}).name || 'this server';
|
||
var idx = i;
|
||
var doReset = function() {
|
||
fetch('./api/nzb-hunt/servers/' + idx + '/bandwidth', { method: 'DELETE' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.success) {
|
||
self._loadServers();
|
||
if (window.HuntarrToast) window.HuntarrToast.success('Bandwidth stats reset for "' + name + '".');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (window.HuntarrToast) window.HuntarrToast.error('Failed to reset stats.');
|
||
});
|
||
};
|
||
if (window.HuntarrConfirm && window.HuntarrConfirm.show) {
|
||
window.HuntarrConfirm.show({ title: 'Reset Bandwidth Stats', message: 'Reset all bandwidth statistics for "' + name + '"? This cannot be undone.', confirmLabel: 'Reset', onConfirm: doReset });
|
||
} else {
|
||
if (!confirm('Reset bandwidth stats for "' + name + '"?')) return;
|
||
doReset();
|
||
}
|
||
} else if (action === 'delete') {
|
||
var name = (self._servers[i] || {}).name || 'this server';
|
||
var idx = i;
|
||
var doDelete = function() {
|
||
fetch('./api/nzb-hunt/servers/' + idx, { method: 'DELETE' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.success) self._loadServers();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Server deleted.', 'success');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Delete failed.', 'error');
|
||
}
|
||
});
|
||
};
|
||
if (window.HuntarrConfirm && window.HuntarrConfirm.show) {
|
||
window.HuntarrConfirm.show({ title: 'Delete Server', message: 'Delete "' + name + '"?', confirmLabel: 'Delete', onConfirm: doDelete });
|
||
} else {
|
||
if (!confirm('Delete "' + name + '"?')) return;
|
||
doDelete();
|
||
}
|
||
}
|
||
});
|
||
|
||
grid.appendChild(card);
|
||
});
|
||
|
||
// Re-add the "Add Server" card at the end
|
||
if (addCard) grid.appendChild(addCard);
|
||
|
||
// Auto-test each server's connection status
|
||
this._testAllServerStatuses();
|
||
},
|
||
|
||
_testAllServerStatuses: function () {
|
||
var self = this;
|
||
this._servers.forEach(function (srv, idx) {
|
||
if (srv.enabled === false) {
|
||
// Disabled servers — mark as offline / disabled
|
||
self._updateServerCardStatus(idx, 'offline', 'Disabled');
|
||
return;
|
||
}
|
||
// Fire off an async test for each enabled server
|
||
// Pass server_index so backend uses the saved password
|
||
var payload = {
|
||
host: srv.host || '',
|
||
port: srv.port || 563,
|
||
ssl: srv.ssl !== false,
|
||
username: srv.username || '',
|
||
password: '',
|
||
server_index: idx
|
||
};
|
||
fetch('./api/nzb-hunt/test-server', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.success) {
|
||
self._updateServerCardStatus(idx, 'online', 'Connected');
|
||
} else {
|
||
self._updateServerCardStatus(idx, 'offline', data.message || 'Connection failed');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
self._updateServerCardStatus(idx, 'offline', 'Test error');
|
||
});
|
||
});
|
||
},
|
||
|
||
_updateServerCardStatus: function (idx, state, message) {
|
||
var dot = document.getElementById('nzb-server-status-' + idx);
|
||
var textEl = document.getElementById('nzb-server-status-text-' + idx);
|
||
|
||
if (dot) {
|
||
dot.className = 'nzb-server-status-dot status-' + state;
|
||
dot.title = message;
|
||
}
|
||
|
||
if (textEl) {
|
||
if (state === 'online') {
|
||
textEl.innerHTML = '<i class="fas fa-check-circle" style="font-size: 11px; color: #22c55e;"></i> <span style="font-size: 12px; color: #4ade80;">Connected</span>';
|
||
} else if (state === 'offline') {
|
||
textEl.innerHTML = '<i class="fas fa-times-circle" style="font-size: 11px; color: #ef4444;"></i> <span style="font-size: 12px; color: #f87171;">' + _esc(message) + '</span>';
|
||
}
|
||
}
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Server Add/Edit (full page editor)
|
||
────────────────────────────────────────────── */
|
||
_serverEditorSetupDone: false,
|
||
|
||
_setupServerEditor: function () {
|
||
if (this._serverEditorSetupDone) return;
|
||
this._serverEditorSetupDone = true;
|
||
|
||
var self = this;
|
||
var backBtn = document.getElementById('nzb-server-editor-back');
|
||
var saveBtn = document.getElementById('nzb-server-editor-save');
|
||
var testBtn = document.getElementById('nzb-server-editor-test');
|
||
|
||
if (backBtn) backBtn.addEventListener('click', function () { self._navigateBackFromServerEditor(); });
|
||
if (saveBtn) saveBtn.addEventListener('click', function () { self._saveServer(); });
|
||
if (testBtn) testBtn.addEventListener('click', function () { self._testServerConnection(); });
|
||
|
||
// When any field changes, update Save button and dirty state
|
||
self._setupServerEditorChangeDetection();
|
||
|
||
// ESC key: navigate back when on server editor page
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') {
|
||
var bm = document.getElementById('nzb-browse-modal');
|
||
if (bm && bm.style.display === 'flex') { self._closeBrowseModal(); return; }
|
||
if (window.huntarrUI && window.huntarrUI.currentSection === 'nzb-hunt-server-editor') {
|
||
self._navigateBackFromServerEditor();
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
_navigateToServerEditor: function () {
|
||
// Propagate setup wizard context to the server editor
|
||
if (this._fromSetupWizard) {
|
||
try { sessionStorage.setItem('setup-wizard-server-editor', '1'); } catch (e) {}
|
||
}
|
||
window.location.hash = 'nzb-hunt-server-editor';
|
||
},
|
||
|
||
_populateServerEditorForm: function () {
|
||
var server = (this._editIndex !== null && this._servers && this._servers[this._editIndex])
|
||
? this._servers[this._editIndex]
|
||
: null;
|
||
|
||
var title = document.getElementById('nzb-server-editor-title');
|
||
if (title) title.textContent = server ? 'Edit Server' : 'Add Server';
|
||
|
||
// Fill fields
|
||
var f = function (id, val) { var el = document.getElementById(id); if (el) { if (el.type === 'checkbox') el.checked = val; else el.value = val; } };
|
||
f('nzb-server-name', server ? server.name : '');
|
||
f('nzb-server-host', server ? server.host : '');
|
||
f('nzb-server-port', server ? (server.port || 563) : 563);
|
||
f('nzb-server-ssl', server ? (server.ssl !== false) : true);
|
||
f('nzb-server-username', server ? (server.username || '') : '');
|
||
// Password: clear the field but show masked version as placeholder
|
||
var pwField = document.getElementById('nzb-server-password');
|
||
if (pwField) {
|
||
pwField.value = '';
|
||
if (server && server.password_masked) {
|
||
pwField.placeholder = server.password_masked;
|
||
} else {
|
||
pwField.placeholder = '';
|
||
}
|
||
}
|
||
f('nzb-server-connections', server ? (server.connections || 8) : 8);
|
||
f('nzb-server-priority', server ? (Math.min(99, Math.max(0, server.priority !== undefined ? server.priority : 0))) : 0);
|
||
f('nzb-server-enabled', server ? (server.enabled !== false) : true);
|
||
|
||
// Store original values for dirty detection
|
||
this._serverEditorOriginalValues = this._getServerEditorFormSnapshot();
|
||
|
||
// Reset test status area
|
||
this._resetTestStatus();
|
||
|
||
this._updateServerModalSaveButton();
|
||
|
||
// Show/hide setup wizard banner on server editor
|
||
var editorFromWizard = false;
|
||
try { editorFromWizard = sessionStorage.getItem('setup-wizard-server-editor') === '1'; } catch (e) {}
|
||
if (editorFromWizard) {
|
||
try { sessionStorage.removeItem('setup-wizard-server-editor'); } catch (e) {}
|
||
}
|
||
var editorBanner = document.getElementById('nzb-server-editor-wizard-banner');
|
||
if (editorBanner) editorBanner.style.display = (editorFromWizard || this._fromSetupWizard) ? 'flex' : 'none';
|
||
// Hide back/breadcrumb during wizard flow (keep save button visible)
|
||
if (editorFromWizard || this._fromSetupWizard) {
|
||
var editorSection = document.getElementById('nzb-hunt-server-editor-section');
|
||
var toolbarLeft = editorSection && editorSection.querySelector('.page-header-bar .reqset-toolbar-left');
|
||
if (toolbarLeft) toolbarLeft.style.display = 'none';
|
||
}
|
||
|
||
// Auto-test connection when editing an existing server
|
||
// Only auto-test if we have host AND credentials (username + password)
|
||
if (server && server.host && server.username) {
|
||
var self = this;
|
||
setTimeout(function () {
|
||
self._showTestStatus('testing', 'Auto-detecting connection...');
|
||
self._testServerConnection(function (ok, msg) {
|
||
if (ok) {
|
||
self._showTestStatus('success', 'Connected to ' + server.host);
|
||
} else {
|
||
self._showTestStatus('fail', 'Could not connect: ' + (msg || 'Unknown error'));
|
||
}
|
||
});
|
||
}, 500);
|
||
}
|
||
},
|
||
|
||
_getServerEditorFormSnapshot: function () {
|
||
var g = function (id) { var el = document.getElementById(id); if (!el) return ''; return el.type === 'checkbox' ? el.checked : el.value; };
|
||
return {
|
||
name: g('nzb-server-name') || '',
|
||
host: (g('nzb-server-host') || '').trim(),
|
||
port: String(parseInt(g('nzb-server-port'), 10) || 563),
|
||
ssl: !!g('nzb-server-ssl'),
|
||
username: g('nzb-server-username') || '',
|
||
password: g('nzb-server-password') || '',
|
||
connections: String(parseInt(g('nzb-server-connections'), 10) || 8),
|
||
priority: String(parseInt(g('nzb-server-priority'), 10) || 0),
|
||
enabled: !!g('nzb-server-enabled')
|
||
};
|
||
},
|
||
|
||
_isServerEditorDirty: function () {
|
||
var orig = this._serverEditorOriginalValues;
|
||
if (!orig) return false;
|
||
var cur = this._getServerEditorFormSnapshot();
|
||
return orig.name !== cur.name || orig.host !== cur.host || orig.port !== cur.port ||
|
||
orig.ssl !== cur.ssl || orig.username !== cur.username || orig.password !== cur.password ||
|
||
orig.connections !== cur.connections || orig.priority !== cur.priority || orig.enabled !== cur.enabled;
|
||
},
|
||
|
||
_updateServerModalSaveButton: function () {
|
||
var saveBtn = document.getElementById('nzb-server-editor-save');
|
||
if (!saveBtn) return;
|
||
var host = (document.getElementById('nzb-server-host') || {}).value;
|
||
var hasHost = (host || '').trim().length > 0;
|
||
var isDirty = this._isServerEditorDirty();
|
||
var canSave = hasHost && isDirty;
|
||
saveBtn.disabled = !canSave;
|
||
saveBtn.title = canSave ? 'Save server' : (hasHost ? 'Save when you make changes' : 'Enter host first');
|
||
},
|
||
|
||
_autoTestTimer: null,
|
||
|
||
_setupServerEditorChangeDetection: function () {
|
||
var self = this;
|
||
var allIds = ['nzb-server-name', 'nzb-server-host', 'nzb-server-port', 'nzb-server-ssl', 'nzb-server-username', 'nzb-server-password', 'nzb-server-connections', 'nzb-server-priority', 'nzb-server-enabled'];
|
||
// Connection-relevant fields trigger auto-test
|
||
var connectionIds = ['nzb-server-host', 'nzb-server-port', 'nzb-server-ssl', 'nzb-server-username', 'nzb-server-password'];
|
||
|
||
allIds.forEach(function (id) {
|
||
var el = document.getElementById(id);
|
||
if (!el) return;
|
||
var handler = function () {
|
||
self._updateServerModalSaveButton();
|
||
// Auto-test when connection-relevant fields change
|
||
// Requires host AND username to be filled before auto-testing
|
||
if (connectionIds.indexOf(id) !== -1) {
|
||
var host = (document.getElementById('nzb-server-host') || {}).value || '';
|
||
var username = (document.getElementById('nzb-server-username') || {}).value || '';
|
||
if (host.trim().length > 3 && username.trim().length > 0) {
|
||
// Debounce: wait 1.5s after last keystroke
|
||
if (self._autoTestTimer) clearTimeout(self._autoTestTimer);
|
||
self._autoTestTimer = setTimeout(function () {
|
||
self._showTestStatus('testing', 'Auto-detecting connection...');
|
||
self._testServerConnection(function (ok, msg) {
|
||
if (ok) {
|
||
self._showTestStatus('success', 'Connected to ' + host.trim());
|
||
} else {
|
||
self._showTestStatus('fail', 'Could not connect: ' + (msg || 'Unknown error'));
|
||
}
|
||
});
|
||
}, 1500);
|
||
}
|
||
}
|
||
};
|
||
el.removeEventListener('input', handler);
|
||
el.removeEventListener('change', handler);
|
||
el.addEventListener('input', handler);
|
||
el.addEventListener('change', handler);
|
||
});
|
||
},
|
||
|
||
_confirmLeaveServerEditor: function (targetSection) {
|
||
var self = this;
|
||
window.HuntarrConfirm.show({
|
||
title: 'Unsaved Changes',
|
||
message: 'You have unsaved changes that will be lost if you leave.',
|
||
confirmLabel: 'Go Back',
|
||
cancelLabel: 'Leave',
|
||
onConfirm: function () { /* Stay on editor */ },
|
||
onCancel: function () {
|
||
self._serverEditorOriginalValues = self._getServerEditorFormSnapshot();
|
||
self._updateServerModalSaveButton();
|
||
// Re-set the wizard flag so the servers page banner shows again
|
||
if (self._fromSetupWizard) {
|
||
try { sessionStorage.setItem('setup-wizard-active-nav', '1'); } catch (e) {}
|
||
}
|
||
if (window.huntarrUI && typeof window.huntarrUI.switchSection === 'function') {
|
||
window.huntarrUI.switchSection(targetSection);
|
||
window.location.hash = targetSection;
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
_navigateBackFromServerEditor: function () {
|
||
if (this._isServerEditorDirty()) {
|
||
this._confirmLeaveServerEditor('nzb-hunt-servers');
|
||
return;
|
||
}
|
||
// Re-set the wizard flag so the servers page banner shows again
|
||
if (this._fromSetupWizard) {
|
||
try { sessionStorage.setItem('setup-wizard-active-nav', '1'); } catch (e) {}
|
||
}
|
||
if (window.huntarrUI && typeof window.huntarrUI.switchSection === 'function') {
|
||
window.huntarrUI.switchSection('nzb-hunt-servers');
|
||
window.location.hash = 'nzb-hunt-servers';
|
||
}
|
||
},
|
||
|
||
_saveServer: function () {
|
||
var g = function (id) { var el = document.getElementById(id); if (!el) return ''; return el.type === 'checkbox' ? el.checked : el.value; };
|
||
var host = (g('nzb-server-host') || '').trim();
|
||
if (!host) {
|
||
this._showTestStatus('fail', 'Host is required.');
|
||
return;
|
||
}
|
||
|
||
var rawPriority = parseInt(g('nzb-server-priority'), 10);
|
||
var priority = (isNaN(rawPriority) ? 0 : Math.min(99, Math.max(0, rawPriority)));
|
||
var payload = {
|
||
name: g('nzb-server-name') || 'Server',
|
||
host: host,
|
||
port: parseInt(g('nzb-server-port'), 10) || 563,
|
||
ssl: !!g('nzb-server-ssl'),
|
||
username: g('nzb-server-username'),
|
||
password: g('nzb-server-password'),
|
||
connections: parseInt(g('nzb-server-connections'), 10) || 8,
|
||
priority: priority,
|
||
enabled: !!g('nzb-server-enabled')
|
||
};
|
||
|
||
var self = this;
|
||
var url, method;
|
||
if (this._editIndex !== null) {
|
||
url = './api/nzb-hunt/servers/' + this._editIndex;
|
||
method = 'PUT';
|
||
} else {
|
||
url = './api/nzb-hunt/servers';
|
||
method = 'POST';
|
||
}
|
||
|
||
// Show testing status in modal before save
|
||
self._showTestStatus('testing', 'Saving & testing connection...');
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.success) {
|
||
self._serverEditorOriginalValues = self._getServerEditorFormSnapshot();
|
||
self._updateServerModalSaveButton();
|
||
self._loadServers();
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Server saved successfully.', 'success');
|
||
}
|
||
// Auto-test connection in background
|
||
var hostName = (document.getElementById('nzb-server-host') || {}).value || 'server';
|
||
self._testServerConnection(function (testSuccess, testMsg) {
|
||
if (testSuccess) {
|
||
self._showTestStatus('success', 'Connected to ' + hostName);
|
||
} else {
|
||
self._showTestStatus('fail', 'Connection to ' + hostName + ' failed: ' + testMsg);
|
||
}
|
||
});
|
||
} else {
|
||
self._showTestStatus('fail', 'Failed to save server.');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
self._showTestStatus('fail', 'Failed to save server.');
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to save server.', 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
/* ── Connection Test Helpers ─────────────────────── */
|
||
|
||
_resetTestStatus: function () {
|
||
var el = document.getElementById('nzb-server-test-status');
|
||
if (el) {
|
||
el.style.display = 'none';
|
||
el.className = 'nzb-server-test-status';
|
||
}
|
||
// Also reset pill
|
||
var pill = document.getElementById('nzb-server-connection-pill');
|
||
if (pill) pill.style.display = 'none';
|
||
},
|
||
|
||
_showTestStatus: function (state, message) {
|
||
// Legacy status bar is permanently hidden — status shown via header pill only
|
||
|
||
// Update connection pill in header
|
||
var pill = document.getElementById('nzb-server-connection-pill');
|
||
var pillIcon = document.getElementById('nzb-server-pill-icon');
|
||
var pillText = document.getElementById('nzb-server-pill-text');
|
||
if (pill) {
|
||
pill.style.display = 'inline-flex';
|
||
pill.className = 'nzb-server-connection-pill pill-' + (state === 'testing' ? 'checking' : state);
|
||
if (pillIcon) {
|
||
if (state === 'testing') pillIcon.className = 'fas fa-circle-notch fa-spin';
|
||
else if (state === 'success') pillIcon.className = 'fas fa-check-circle';
|
||
else pillIcon.className = 'fas fa-times-circle';
|
||
}
|
||
if (pillText) {
|
||
// Show short text in pill
|
||
if (state === 'testing') pillText.textContent = 'Checking...';
|
||
else if (state === 'success') {
|
||
var host = (document.getElementById('nzb-server-host') || {}).value || '';
|
||
pillText.textContent = 'Connected' + (host ? ' to ' + host.trim() : '');
|
||
} else {
|
||
pillText.textContent = 'Connection Failed';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
_testServerConnection: function (callback) {
|
||
var g = function (id) { var el = document.getElementById(id); if (!el) return ''; return el.type === 'checkbox' ? el.checked : el.value; };
|
||
var host = (g('nzb-server-host') || '').trim();
|
||
if (!host) {
|
||
this._showTestStatus('fail', 'Host is required to test connection.');
|
||
if (callback) callback(false, 'Host is required');
|
||
return;
|
||
}
|
||
|
||
var payload = {
|
||
host: host,
|
||
port: parseInt(g('nzb-server-port'), 10) || 563,
|
||
ssl: !!g('nzb-server-ssl'),
|
||
username: (g('nzb-server-username') || '').trim(),
|
||
password: (g('nzb-server-password') || '').trim()
|
||
};
|
||
|
||
// If editing an existing server and password field is empty,
|
||
// pass server_index so backend can use the saved password
|
||
if (!payload.password && this._editIndex !== null) {
|
||
payload.server_index = this._editIndex;
|
||
}
|
||
|
||
var self = this;
|
||
if (!callback) {
|
||
// Manual test button click – show testing state
|
||
self._showTestStatus('testing', 'Testing connection to ' + host + ':' + payload.port + '...');
|
||
}
|
||
|
||
fetch('./api/nzb-hunt/test-server', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (callback) {
|
||
callback(data.success, data.message || '');
|
||
} else {
|
||
if (data.success) {
|
||
self._showTestStatus('success', 'Connected to ' + host + '.');
|
||
} else {
|
||
self._showTestStatus('fail', 'Connection to ' + host + ' failed: ' + (data.message || 'Unknown error'));
|
||
}
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
var errMsg = 'Network error testing connection.';
|
||
if (callback) {
|
||
callback(false, errMsg);
|
||
} else {
|
||
self._showTestStatus('fail', errMsg);
|
||
}
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Categories – CRUD + card rendering
|
||
────────────────────────────────────────────── */
|
||
_categoriesBaseFolder: '/downloads/complete', // Internal base folder for auto-gen
|
||
|
||
_getBaseFolder: function () {
|
||
return this._categoriesBaseFolder || '/downloads/complete';
|
||
},
|
||
|
||
_setupCategoryGrid: function () {
|
||
// Categories are auto-generated from instances — no Add/Edit/Delete
|
||
},
|
||
|
||
_loadCategories: function () {
|
||
var self = this;
|
||
fetch('./api/nzb-hunt/categories?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
self._categories = data.categories || [];
|
||
if (data.base_folder) self._categoriesBaseFolder = data.base_folder;
|
||
// Ensure folder creation and get status (success/error per category)
|
||
return fetch('./api/nzb-hunt/categories/ensure-folders', { method: 'POST' })
|
||
.then(function (r2) { return r2.json(); })
|
||
.then(function (ensureData) {
|
||
var statusMap = {};
|
||
(ensureData.status || []).forEach(function (s) {
|
||
statusMap[s.name] = { ok: s.ok, error: s.error };
|
||
});
|
||
self._categories.forEach(function (c) {
|
||
var st = statusMap[c.name] || {};
|
||
c._folderOk = st.ok;
|
||
c._folderError = st.error;
|
||
});
|
||
})
|
||
.catch(function () { /* keep categories, render without status */ });
|
||
})
|
||
.then(function () { self._renderCategoryCards(); })
|
||
.catch(function () { self._categories = []; self._renderCategoryCards(); });
|
||
},
|
||
|
||
_renderCategoryCards: function () {
|
||
var grid = document.getElementById('nzb-cat-grid');
|
||
if (!grid) return;
|
||
grid.innerHTML = '';
|
||
|
||
var self = this;
|
||
this._categories.forEach(function (cat) {
|
||
var card = document.createElement('div');
|
||
card.className = 'nzb-cat-card nzb-cat-card-readonly';
|
||
var statusIcon = cat._folderOk ? '<i class="fas fa-check-circle nzb-cat-status-ok" title="Folder created and writeable"></i>' :
|
||
(cat._folderError ? '<i class="fas fa-exclamation-circle nzb-cat-status-error" title="' + _esc(cat._folderError || 'Error') + '"></i>' : '');
|
||
card.innerHTML =
|
||
'<div class="nzb-cat-card-header">' +
|
||
'<div class="nzb-cat-card-name"><i class="fas fa-tag"></i> <span>' + _esc(cat.name || 'Category') + '</span></div>' +
|
||
'<div class="nzb-cat-card-badges">' +
|
||
'<span class="nzb-badge nzb-badge-priority-cat">' + _esc(_capFirst(cat.priority || 'normal')) + '</span>' +
|
||
(statusIcon ? '<span class="nzb-cat-status">' + statusIcon + '</span>' : '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="nzb-cat-card-body">' +
|
||
'<div class="nzb-cat-card-path nzb-cat-path-readonly"><i class="fas fa-folder"></i> <span>' + _esc(cat.folder || '') + '</span></div>' +
|
||
(cat._folderError ? '<div class="nzb-cat-error-msg">' + _esc(cat._folderError) + '</div>' : '') +
|
||
'</div>';
|
||
grid.appendChild(card);
|
||
});
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Category Add/Edit Modal
|
||
────────────────────────────────────────────── */
|
||
_setupCategoryModal: function () {
|
||
// Categories are auto-generated — no Add/Edit modal
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Processing – load / save (merged into Advanced)
|
||
────────────────────────────────────────────── */
|
||
_loadProcessing: function () {
|
||
fetch('./api/nzb-hunt/settings/processing?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var el;
|
||
el = document.getElementById('nzb-proc-max-retries');
|
||
if (el && data.max_retries !== undefined) el.value = data.max_retries;
|
||
|
||
el = document.getElementById('nzb-proc-abort-hopeless');
|
||
if (el) el.checked = data.abort_hopeless !== false;
|
||
|
||
el = document.getElementById('nzb-proc-abort-threshold');
|
||
if (el && data.abort_threshold_pct !== undefined) el.value = data.abort_threshold_pct;
|
||
|
||
el = document.getElementById('nzb-proc-propagation-delay');
|
||
if (el && data.propagation_delay !== undefined) el.value = data.propagation_delay;
|
||
|
||
el = document.getElementById('nzb-proc-disconnect-empty');
|
||
if (el) el.checked = data.disconnect_on_empty !== false;
|
||
|
||
el = document.getElementById('nzb-proc-direct-unpack');
|
||
if (el) el.checked = !!data.direct_unpack;
|
||
|
||
el = document.getElementById('nzb-proc-encrypted-rar');
|
||
if (el && data.encrypted_rar_action) el.value = data.encrypted_rar_action;
|
||
|
||
el = document.getElementById('nzb-proc-unwanted-action');
|
||
if (el && data.unwanted_ext_action) el.value = data.unwanted_ext_action;
|
||
|
||
el = document.getElementById('nzb-proc-unwanted-ext');
|
||
if (el && data.unwanted_extensions !== undefined) el.value = data.unwanted_extensions;
|
||
|
||
el = document.getElementById('nzb-proc-identical-detection');
|
||
if (el && data.identical_detection) el.value = data.identical_detection;
|
||
|
||
el = document.getElementById('nzb-proc-smart-detection');
|
||
if (el && data.smart_detection) el.value = data.smart_detection;
|
||
|
||
el = document.getElementById('nzb-proc-allow-proper');
|
||
if (el) el.checked = data.allow_proper !== false;
|
||
|
||
// Hide threshold row if abort is off
|
||
var abortEl = document.getElementById('nzb-proc-abort-hopeless');
|
||
var thresholdRow = document.getElementById('nzb-proc-abort-threshold-row');
|
||
if (abortEl && thresholdRow) {
|
||
thresholdRow.style.display = abortEl.checked ? '' : 'none';
|
||
}
|
||
})
|
||
.catch(function () { /* use defaults */ });
|
||
},
|
||
|
||
/* ──────────────────────────────────────────────
|
||
Advanced settings (includes Processing)
|
||
────────────────────────────────────────────── */
|
||
_setupAdvanced: function () {
|
||
var self = this;
|
||
// Header save button (primary)
|
||
var headerSaveBtn = document.getElementById('nzb-save-advanced-header');
|
||
if (headerSaveBtn) {
|
||
headerSaveBtn.addEventListener('click', function () { self._saveAdvanced(); });
|
||
}
|
||
// Legacy bottom save button (fallback)
|
||
var saveBtn = document.getElementById('nzb-save-advanced');
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', function () { self._saveAdvanced(); });
|
||
}
|
||
// Show/hide abort threshold row based on toggle (processing settings in Advanced)
|
||
var abortToggle = document.getElementById('nzb-proc-abort-hopeless');
|
||
var thresholdRow = document.getElementById('nzb-proc-abort-threshold-row');
|
||
if (abortToggle && thresholdRow) {
|
||
abortToggle.addEventListener('change', function () {
|
||
thresholdRow.style.display = abortToggle.checked ? '' : 'none';
|
||
});
|
||
}
|
||
},
|
||
|
||
_loadAdvanced: function () {
|
||
fetch('./api/nzb-hunt/settings/advanced?t=' + Date.now())
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var el;
|
||
el = document.getElementById('nzb-adv-receive-threads');
|
||
if (el && data.receive_threads !== undefined) el.value = data.receive_threads;
|
||
|
||
el = document.getElementById('nzb-adv-sleep-time');
|
||
if (el && data.downloader_sleep_time !== undefined) el.value = data.downloader_sleep_time;
|
||
|
||
el = document.getElementById('nzb-adv-unpack-threads');
|
||
if (el && data.direct_unpack_threads !== undefined) el.value = data.direct_unpack_threads;
|
||
|
||
el = document.getElementById('nzb-adv-size-limit');
|
||
if (el && data.size_limit !== undefined) el.value = data.size_limit;
|
||
|
||
el = document.getElementById('nzb-adv-completion-rate');
|
||
if (el && data.req_completion_rate !== undefined) el.value = data.req_completion_rate;
|
||
|
||
el = document.getElementById('nzb-adv-url-retries');
|
||
if (el && data.max_url_retries !== undefined) el.value = data.max_url_retries;
|
||
})
|
||
.catch(function () { /* use defaults */ });
|
||
},
|
||
|
||
_saveAdvanced: function () {
|
||
var advPayload = {
|
||
receive_threads: parseInt((document.getElementById('nzb-adv-receive-threads') || {}).value || '2', 10),
|
||
downloader_sleep_time: parseInt((document.getElementById('nzb-adv-sleep-time') || {}).value || '10', 10),
|
||
direct_unpack_threads: parseInt((document.getElementById('nzb-adv-unpack-threads') || {}).value || '3', 10),
|
||
size_limit: (document.getElementById('nzb-adv-size-limit') || {}).value || '',
|
||
req_completion_rate: parseFloat((document.getElementById('nzb-adv-completion-rate') || {}).value || '100.2'),
|
||
max_url_retries: parseInt((document.getElementById('nzb-adv-url-retries') || {}).value || '10', 10)
|
||
};
|
||
var procPayload = {
|
||
max_retries: parseInt((document.getElementById('nzb-proc-max-retries') || {}).value || '3', 10),
|
||
abort_hopeless: !!(document.getElementById('nzb-proc-abort-hopeless') || {}).checked,
|
||
abort_threshold_pct: parseInt((document.getElementById('nzb-proc-abort-threshold') || {}).value || '5', 10),
|
||
propagation_delay: parseInt((document.getElementById('nzb-proc-propagation-delay') || {}).value || '0', 10),
|
||
disconnect_on_empty: !!(document.getElementById('nzb-proc-disconnect-empty') || {}).checked,
|
||
direct_unpack: !!(document.getElementById('nzb-proc-direct-unpack') || {}).checked,
|
||
encrypted_rar_action: (document.getElementById('nzb-proc-encrypted-rar') || {}).value || 'pause',
|
||
unwanted_ext_action: (document.getElementById('nzb-proc-unwanted-action') || {}).value || 'off',
|
||
unwanted_extensions: (document.getElementById('nzb-proc-unwanted-ext') || {}).value || '',
|
||
identical_detection: (document.getElementById('nzb-proc-identical-detection') || {}).value || 'on',
|
||
smart_detection: (document.getElementById('nzb-proc-smart-detection') || {}).value || 'on',
|
||
allow_proper: !!(document.getElementById('nzb-proc-allow-proper') || {}).checked
|
||
};
|
||
|
||
var self = this;
|
||
Promise.all([
|
||
fetch('./api/nzb-hunt/settings/advanced', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(advPayload)
|
||
}).then(function (r) { return r.json(); }),
|
||
fetch('./api/nzb-hunt/settings/processing', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(procPayload)
|
||
}).then(function (r) { return r.json(); })
|
||
])
|
||
.then(function (results) {
|
||
var advOk = results[0] && results[0].success;
|
||
var procOk = results[1] && results[1].success;
|
||
if ((advOk || procOk) && window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Advanced settings saved.', 'success');
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to save advanced settings.', 'error');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
})();
|
||
|
||
|
||
/* === modules/features/indexer-hunt.js === */
|
||
/**
|
||
* Indexer Hunt — Centralized indexer management module.
|
||
* Full-page editor (no modal), card grid list.
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
var _indexers = [];
|
||
var _presets = [];
|
||
var _editingId = null;
|
||
var _initialized = false;
|
||
|
||
var IH = window.IndexerHunt = {};
|
||
|
||
// ── Initialization ────────────────────────────────────────────────
|
||
|
||
function _updateSetupWizardBanner() {
|
||
var banner = document.getElementById('indexer-setup-wizard-continue-banner');
|
||
var callout = document.getElementById('indexer-instance-setup-callout');
|
||
// Show if user navigated here from the setup wizard.
|
||
// Don't remove the flag — it needs to persist across re-renders during the wizard flow.
|
||
var fromWizard = false;
|
||
try { fromWizard = sessionStorage.getItem('setup-wizard-active-nav') === '1'; } catch (e) {}
|
||
if (banner) banner.style.display = fromWizard ? 'flex' : 'none';
|
||
if (callout) callout.style.display = fromWizard ? 'flex' : 'none';
|
||
}
|
||
|
||
// ── Instance indexer status checklist ────────────────────────────
|
||
function _refreshIndexerInstanceStatus() {
|
||
var gridEl = document.getElementById('indexer-instance-status-grid');
|
||
var statusArea = document.getElementById('indexer-instance-status-area');
|
||
if (!gridEl) return;
|
||
gridEl.innerHTML = '<div style="padding: 12px; color: #94a3b8;"><i class="fas fa-spinner fa-spin"></i> Checking instances...</div>';
|
||
var ts = '?t=' + Date.now();
|
||
Promise.all([
|
||
fetch('./api/movie-hunt/instances' + ts, { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/tv-hunt/instances' + ts, { cache: 'no-store' }).then(function(r) { return r.json(); })
|
||
]).then(function(results) {
|
||
var movieInstances = (results[0].instances || []).map(function(i) { return { value: 'movie:' + i.id, label: 'Movie - ' + (i.name || 'Instance ' + i.id), id: i.id, type: 'movie' }; });
|
||
var tvInstances = (results[1].instances || []).map(function(i) { return { value: 'tv:' + i.id, label: 'TV - ' + (i.name || 'Instance ' + i.id), id: i.id, type: 'tv' }; });
|
||
var all = movieInstances.concat(tvInstances);
|
||
if (all.length === 0) {
|
||
gridEl.innerHTML = '';
|
||
if (statusArea) statusArea.style.display = 'none';
|
||
return;
|
||
}
|
||
var fetches = all.map(function(inst) {
|
||
var url = inst.type === 'tv' ? './api/tv-hunt/indexers' : './api/indexers';
|
||
url += '?instance_id=' + encodeURIComponent(inst.id) + '&t=' + Date.now();
|
||
return fetch(url, { cache: 'no-store' }).then(function(r) { return r.json(); }).then(function(d) {
|
||
var indexers = d.indexers || [];
|
||
return { label: inst.label, value: inst.value, hasIndexers: indexers.length > 0 };
|
||
}).catch(function() {
|
||
return { label: inst.label, value: inst.value, hasIndexers: false };
|
||
});
|
||
});
|
||
Promise.all(fetches).then(function(statuses) {
|
||
var allGood = statuses.every(function(s) { return s.hasIndexers; });
|
||
// Hide the status area if all instances have indexers
|
||
if (allGood) {
|
||
gridEl.innerHTML = '';
|
||
if (statusArea) statusArea.style.display = 'none';
|
||
return;
|
||
}
|
||
if (statusArea) statusArea.style.display = 'block';
|
||
var html = '';
|
||
for (var i = 0; i < statuses.length; i++) {
|
||
var s = statuses[i];
|
||
var cardClass = s.hasIndexers ? 'instance-complete' : 'instance-not-setup';
|
||
var iconClass = s.hasIndexers ? 'fa-check-circle' : 'fa-search-plus';
|
||
var badgeText = s.hasIndexers ? 'Indexers Assigned' : 'No Indexers';
|
||
var nameEsc = (s.label || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
html += '<div class="root-folders-instance-status-card ' + cardClass + '" data-value="' + (s.value || '').replace(/"/g, '"') + '">' +
|
||
'<div class="instance-status-icon"><i class="fas ' + iconClass + '" aria-hidden="true"></i></div>' +
|
||
'<div class="instance-status-body">' +
|
||
'<div class="instance-status-name">' + nameEsc + '</div>' +
|
||
'<span class="instance-status-badge">' + badgeText + '</span>' +
|
||
'</div></div>';
|
||
}
|
||
gridEl.innerHTML = html;
|
||
// Click to switch instance dropdown
|
||
gridEl.querySelectorAll('.root-folders-instance-status-card').forEach(function(card) {
|
||
var val = card.getAttribute('data-value');
|
||
if (val) {
|
||
card.style.cursor = 'pointer';
|
||
card.addEventListener('click', function() {
|
||
var sel = document.getElementById('settings-indexers-instance-select');
|
||
if (sel && val) {
|
||
sel.value = val;
|
||
sel.dispatchEvent(new Event('change'));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}).catch(function() {
|
||
gridEl.innerHTML = '';
|
||
if (statusArea) statusArea.style.display = 'none';
|
||
});
|
||
}
|
||
// Expose for refresh after import/delete
|
||
IH._refreshIndexerInstanceStatus = _refreshIndexerInstanceStatus;
|
||
|
||
IH.init = function() {
|
||
var searchInput = document.getElementById('ih-search-input');
|
||
if (searchInput) searchInput.value = '';
|
||
if (!_initialized) {
|
||
_bindEvents();
|
||
_initialized = true;
|
||
}
|
||
_updateSetupWizardBanner();
|
||
var noInstEl = document.getElementById('indexer-hunt-no-instances');
|
||
var wrapperEl = document.getElementById('indexer-hunt-content-wrapper');
|
||
Promise.all([
|
||
fetch('./api/movie-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/tv-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); })
|
||
]).then(function(results) {
|
||
var movieCount = (results[0].instances || []).length;
|
||
var tvCount = (results[1].instances || []).length;
|
||
if (movieCount === 0 && tvCount === 0) {
|
||
if (noInstEl) noInstEl.style.display = '';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_showListView();
|
||
_loadPresets(function() {
|
||
_loadIndexers();
|
||
});
|
||
_refreshIndexerInstanceStatus();
|
||
}).catch(function() {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_showListView();
|
||
_loadPresets(function() {
|
||
_loadIndexers();
|
||
});
|
||
_refreshIndexerInstanceStatus();
|
||
});
|
||
};
|
||
|
||
function _bindEvents() {
|
||
_on('ih-add-btn', 'click', function() { _openEditor(null); });
|
||
_on('ih-empty-add-btn', 'click', function() { _openEditor(null); });
|
||
_on('ih-editor-back', 'click', function() { _showListView(); });
|
||
_on('ih-editor-save', 'click', _saveForm);
|
||
_on('ih-search-input', 'input', function() { _renderCards(); });
|
||
_on('ih-form-preset', 'change', _onPresetChange);
|
||
|
||
// "Import from Index Master" card: show select list (ih-import-panel)
|
||
var wrapper = document.getElementById('indexer-hunt-content-wrapper');
|
||
if (wrapper) {
|
||
wrapper.addEventListener('click', function(e) {
|
||
var card = e.target.closest('.add-instance-card[data-source="indexer-hunt"]');
|
||
if (card) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_openIHImportPanel();
|
||
}
|
||
});
|
||
// Edit/Delete on instance indexer cards (capture so we handle before other listeners)
|
||
wrapper.addEventListener('click', _onInstanceIndexerCardClick, true);
|
||
}
|
||
var cancelBtn = document.getElementById('ih-import-cancel');
|
||
if (cancelBtn) cancelBtn.addEventListener('click', _closeIHImportPanel);
|
||
var confirmBtn = document.getElementById('ih-import-confirm');
|
||
if (confirmBtn) confirmBtn.addEventListener('click', _confirmIHImport);
|
||
}
|
||
|
||
function _getInstanceIdAndMode() {
|
||
var sel = document.getElementById('settings-indexers-instance-select');
|
||
var val = (sel && sel.value) ? sel.value.trim() : '';
|
||
if (!val) return { instanceId: 1, mode: 'movie' };
|
||
var parts = val.split(':');
|
||
if (parts.length === 2) {
|
||
var mode = parts[0] === 'tv' ? 'tv' : 'movie';
|
||
var id = parseInt(parts[1], 10);
|
||
return { instanceId: isNaN(id) ? 1 : id, mode: mode };
|
||
}
|
||
return { instanceId: 1, mode: 'movie' };
|
||
}
|
||
|
||
function _openIHImportPanel() {
|
||
var panel = document.getElementById('ih-import-panel');
|
||
var list = document.getElementById('ih-import-list');
|
||
var actions = document.getElementById('ih-import-actions');
|
||
if (panel) panel.style.display = 'block';
|
||
if (list) list.innerHTML = '<div style="color: #94a3b8; padding: 20px; text-align: center;"><i class="fas fa-spinner fa-spin"></i> Loading available indexers...</div>';
|
||
if (actions) actions.style.display = 'none';
|
||
|
||
var par = _getInstanceIdAndMode();
|
||
var url = './api/indexer-hunt/available/' + par.instanceId + '?mode=' + encodeURIComponent(par.mode);
|
||
|
||
fetch(url)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var available = data.available || [];
|
||
if (available.length === 0) {
|
||
if (list) list.innerHTML = '<div class="ih-import-empty"><i class="fas fa-check-circle" style="color: #10b981; margin-right: 6px;"></i>All Index Master indexers are already imported to this instance.</div>';
|
||
return;
|
||
}
|
||
var html = '';
|
||
available.forEach(function(idx) {
|
||
var keyDisplay = idx.api_key_last4 ? '\u2022\u2022\u2022\u2022' + _esc(idx.api_key_last4) : 'No key';
|
||
html += '<div class="ih-import-item" data-ih-id="' + idx.id + '">'
|
||
+ '<div class="ih-import-checkbox"><i class="fas fa-check"></i></div>'
|
||
+ '<div class="ih-import-info">'
|
||
+ '<div class="ih-import-name">' + _esc(idx.name) + '</div>'
|
||
+ '<div class="ih-import-meta">'
|
||
+ '<span><i class="fas fa-globe"></i> ' + _esc(idx.url || 'N/A') + '</span>'
|
||
+ '<span><i class="fas fa-sort-amount-up"></i> Priority: ' + (idx.priority || 50) + '</span>'
|
||
+ '<span><i class="fas fa-key"></i> ' + keyDisplay + '</span>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
});
|
||
if (list) list.innerHTML = html;
|
||
if (actions) actions.style.display = 'flex';
|
||
|
||
var items = list.querySelectorAll('.ih-import-item');
|
||
items.forEach(function(item) {
|
||
item.addEventListener('click', function() {
|
||
item.classList.toggle('selected');
|
||
_updateIHImportButton();
|
||
});
|
||
});
|
||
})
|
||
.catch(function(err) {
|
||
if (list) list.innerHTML = '<div class="ih-import-empty">Failed to load available indexers.</div>';
|
||
});
|
||
}
|
||
|
||
function _closeIHImportPanel() {
|
||
var panel = document.getElementById('ih-import-panel');
|
||
if (panel) panel.style.display = 'none';
|
||
}
|
||
|
||
function _onInstanceIndexerCardClick(e) {
|
||
var grid = e.target.closest('#indexer-instances-grid-unified');
|
||
if (!grid || !grid.closest('#indexer-hunt-section')) return;
|
||
var editBtn = e.target.closest('.btn-card.edit[data-app-type="indexer"]');
|
||
var deleteBtn = e.target.closest('.btn-card.delete[data-app-type="indexer"]');
|
||
if (editBtn) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var card = editBtn.closest('.instance-card');
|
||
if (!card) return;
|
||
var index = parseInt(card.getAttribute('data-instance-index'), 10);
|
||
if (isNaN(index)) return;
|
||
var list = window.SettingsForms && window.SettingsForms._indexersList;
|
||
if (!list || index < 0 || index >= list.length) return;
|
||
if (window.SettingsForms && window.SettingsForms.openIndexerEditor) {
|
||
window.SettingsForms.openIndexerEditor(false, index, list[index]);
|
||
}
|
||
return;
|
||
}
|
||
if (deleteBtn) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var card = deleteBtn.closest('.instance-card');
|
||
if (!card) return;
|
||
var index = parseInt(card.getAttribute('data-instance-index'), 10);
|
||
if (isNaN(index)) return;
|
||
var list = window.SettingsForms && window.SettingsForms._indexersList;
|
||
if (!list || index < 0 || index >= list.length) return;
|
||
var indexer = list[index];
|
||
var name = (indexer && indexer.name) ? indexer.name : 'Unnamed';
|
||
var Forms = window.SettingsForms;
|
||
var isTV = Forms._indexersMode === 'tv';
|
||
var deleteId = isTV && indexer && indexer.id ? indexer.id : index;
|
||
if (window.HuntarrConfirm && window.HuntarrConfirm.show) {
|
||
window.HuntarrConfirm.show({
|
||
title: 'Delete Indexer',
|
||
message: 'Are you sure you want to remove "' + name + '" from this instance? It will no longer be used for searches and will be removed from Index Master tracking for this instance.',
|
||
confirmLabel: 'Delete',
|
||
onConfirm: function() {
|
||
var apiBase = Forms.getIndexersApiBase();
|
||
var url = apiBase + '/' + encodeURIComponent(String(deleteId));
|
||
fetch(url, { method: 'DELETE' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success !== false) {
|
||
if (window.SettingsForms && window.SettingsForms.refreshIndexersList) {
|
||
window.SettingsForms.refreshIndexersList();
|
||
}
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Indexer removed.', 'success');
|
||
}
|
||
_refreshIndexerInstanceStatus();
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(data.error || 'Failed to remove indexer.', 'error');
|
||
}
|
||
}
|
||
})
|
||
.catch(function() {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Failed to remove indexer.', 'error');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function _updateIHImportButton() {
|
||
var selected = document.querySelectorAll('#ih-import-list .ih-import-item.selected');
|
||
var btn = document.getElementById('ih-import-confirm');
|
||
if (btn) {
|
||
btn.disabled = selected.length === 0;
|
||
btn.innerHTML = '<i class="fas fa-download"></i> Import Selected (' + selected.length + ')';
|
||
}
|
||
}
|
||
|
||
function _confirmIHImport() {
|
||
var selected = document.querySelectorAll('#ih-import-list .ih-import-item.selected');
|
||
if (selected.length === 0) return;
|
||
|
||
var ids = [];
|
||
selected.forEach(function(item) {
|
||
ids.push(item.getAttribute('data-ih-id'));
|
||
});
|
||
var par = _getInstanceIdAndMode();
|
||
|
||
var btn = document.getElementById('ih-import-confirm');
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...'; }
|
||
|
||
fetch('./api/indexer-hunt/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instance_id: par.instanceId, mode: par.mode, indexer_ids: ids }),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
var msg = 'Imported ' + (data.added || 0) + ' indexer(s) from Index Master.';
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(msg, 'success');
|
||
_closeIHImportPanel();
|
||
if (window.SettingsForms && window.SettingsForms.refreshIndexersList) {
|
||
window.SettingsForms.refreshIndexersList();
|
||
}
|
||
_refreshIndexerInstanceStatus();
|
||
} else {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(data.error || 'Import failed.', 'error');
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('Import error.', 'error');
|
||
})
|
||
.finally(function() {
|
||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-download"></i> Import Selected'; }
|
||
});
|
||
}
|
||
|
||
function _on(id, event, fn) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.addEventListener(event, fn);
|
||
}
|
||
|
||
// ── View switching ─────────────────────────────────────────────────
|
||
|
||
function _showListView() {
|
||
var list = document.getElementById('ih-list-view');
|
||
var editor = document.getElementById('ih-editor-view');
|
||
if (list) list.style.display = '';
|
||
if (editor) editor.style.display = 'none';
|
||
_editingId = null;
|
||
}
|
||
|
||
function _showEditorView() {
|
||
var list = document.getElementById('ih-list-view');
|
||
var editor = document.getElementById('ih-editor-view');
|
||
if (list) list.style.display = 'none';
|
||
if (editor) editor.style.display = '';
|
||
// Anchor editor into view so user doesn't have to scroll down
|
||
if (editor) {
|
||
editor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
// ── Data loading ──────────────────────────────────────────────────
|
||
|
||
function _loadPresets(cb) {
|
||
fetch('./api/indexer-hunt/presets')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
_presets = data.presets || [];
|
||
_populatePresetDropdown();
|
||
if (cb) cb();
|
||
})
|
||
.catch(function() { if (cb) cb(); });
|
||
}
|
||
|
||
function _loadIndexers() {
|
||
fetch('./api/indexer-hunt/indexers')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
_indexers = data.indexers || [];
|
||
_renderCards();
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[IndexerHunt] Load error:', err);
|
||
});
|
||
}
|
||
|
||
function _populatePresetDropdown() {
|
||
var sel = document.getElementById('ih-form-preset');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="manual">Custom (Manual)</option>';
|
||
_presets.forEach(function(p) {
|
||
var opt = document.createElement('option');
|
||
opt.value = p.key;
|
||
opt.textContent = p.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// ── Card rendering ─────────────────────────────────────────────────
|
||
|
||
function _renderCards() {
|
||
var grid = document.getElementById('ih-card-grid');
|
||
var empty = document.getElementById('ih-empty-state');
|
||
if (!grid) return;
|
||
|
||
var query = (document.getElementById('ih-search-input') || {}).value || '';
|
||
query = query.toLowerCase().trim();
|
||
|
||
var filtered = _indexers;
|
||
if (query) {
|
||
filtered = _indexers.filter(function(idx) {
|
||
return (idx.name || '').toLowerCase().indexOf(query) !== -1 ||
|
||
(idx.url || '').toLowerCase().indexOf(query) !== -1 ||
|
||
(idx.preset || '').toLowerCase().indexOf(query) !== -1;
|
||
});
|
||
}
|
||
|
||
if (filtered.length === 0 && _indexers.length === 0) {
|
||
grid.style.display = 'none';
|
||
if (empty) empty.style.display = '';
|
||
var poolNotice = document.getElementById('ih-pool-notice');
|
||
if (poolNotice) poolNotice.style.display = 'none';
|
||
var instanceArea = document.getElementById('ih-instance-area');
|
||
if (instanceArea) instanceArea.style.display = 'none';
|
||
var groupBox = document.getElementById('ih-group-box');
|
||
if (groupBox) groupBox.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
grid.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
var poolNotice = document.getElementById('ih-pool-notice');
|
||
if (poolNotice) poolNotice.style.display = '';
|
||
var instanceArea = document.getElementById('ih-instance-area');
|
||
if (instanceArea) instanceArea.style.display = '';
|
||
var groupBox = document.getElementById('ih-group-box');
|
||
if (groupBox) groupBox.style.display = '';
|
||
|
||
var html = '';
|
||
filtered.forEach(function(idx) {
|
||
var enabled = idx.enabled !== false;
|
||
var statusClass = enabled ? 'enabled' : 'disabled';
|
||
var statusText = enabled ? 'Enabled' : 'Disabled';
|
||
var statusIcon = enabled ? 'fa-check-circle' : 'fa-minus-circle';
|
||
var presetLabel = _getPresetLabel(idx.preset);
|
||
var url = idx.url || '\u2014';
|
||
var keyDisplay = idx.api_key_last4 ? '\u2022\u2022\u2022\u2022' + _esc(idx.api_key_last4) : 'No key';
|
||
html += '<div class="ih-card' + (enabled ? '' : ' ih-card-disabled') + '" data-id="' + _esc(idx.id) + '">'
|
||
+ '<div class="ih-card-header">'
|
||
+ '<div class="ih-card-name"><span>' + _esc(idx.name || '') + '</span></div>'
|
||
+ '<span class="ih-card-status ' + statusClass + '"><i class="fas ' + statusIcon + '"></i> ' + statusText + '</span>'
|
||
+ '</div>'
|
||
+ '<div class="ih-card-body">'
|
||
+ '<div class="ih-card-detail ih-card-connection-row"><span class="ih-card-connection-status" data-connection="pending"><i class="fas fa-spinner fa-spin"></i> Checking...</span></div>'
|
||
+ '<div class="ih-card-detail"><i class="fas fa-globe"></i><span class="ih-detail-value">' + _esc(url) + '</span></div>'
|
||
+ '<div class="ih-card-detail"><i class="fas fa-key"></i><span class="ih-detail-value">' + keyDisplay + '</span></div>'
|
||
+ '<div class="ih-card-detail" style="gap: 8px;">'
|
||
+ '<span class="ih-card-priority-badge"><i class="fas fa-sort-amount-up" style="font-size:0.7rem;"></i> ' + (idx.priority || 50) + '</span>'
|
||
+ '<span class="ih-card-preset-badge">' + _esc(presetLabel) + '</span>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="ih-card-footer">'
|
||
+ '<button class="ih-card-btn test" onclick="IndexerHunt.testIndexer(\'' + _esc(idx.id) + '\')" title="Test"><i class="fas fa-plug"></i> Test</button>'
|
||
+ '<button class="ih-card-btn edit" onclick="IndexerHunt.editIndexer(\'' + _esc(idx.id) + '\')" title="Edit"><i class="fas fa-edit"></i> Edit</button>'
|
||
+ '<button class="ih-card-btn delete" onclick="IndexerHunt.deleteIndexer(\'' + _esc(idx.id) + '\', \'' + _esc(idx.name) + '\')" title="Delete"><i class="fas fa-trash"></i></button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
});
|
||
|
||
// Add card at the end
|
||
html += '<div class="ih-add-card" id="ih-add-card-inline">'
|
||
+ '<div class="ih-add-icon"><i class="fas fa-plus-circle"></i></div>'
|
||
+ '<div class="ih-add-text">Add Indexer</div>'
|
||
+ '</div>';
|
||
|
||
grid.innerHTML = html;
|
||
|
||
var addCard = document.getElementById('ih-add-card-inline');
|
||
if (addCard) addCard.addEventListener('click', function() { _openEditor(null); });
|
||
|
||
// Test each indexer connection and update card status (like app settings)
|
||
_testIndexerCardsConnectionStatus(filtered);
|
||
}
|
||
|
||
function _testIndexerCardsConnectionStatus(indexerList) {
|
||
if (!indexerList || indexerList.length === 0) return;
|
||
fetch('./api/indexer-hunt/status')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var statuses = data.statuses || {};
|
||
indexerList.forEach(function(idx) {
|
||
var card = document.querySelector('.ih-card[data-id="' + idx.id + '"]');
|
||
var statusEl = card ? card.querySelector('.ih-card-connection-status') : null;
|
||
if (!statusEl) return;
|
||
var info = statuses[idx.id];
|
||
if (!info) {
|
||
statusEl.setAttribute('data-connection', 'pending');
|
||
statusEl.innerHTML = '<i class="fas fa-clock"></i> Pending';
|
||
statusEl.classList.add('ih-card-connection-pending');
|
||
statusEl.classList.remove('ih-card-connection-ok', 'ih-card-connection-fail');
|
||
return;
|
||
}
|
||
if (info.status === 'connected') {
|
||
statusEl.setAttribute('data-connection', 'connected');
|
||
var timeAgo = info.last_checked ? _timeAgo(info.last_checked) : '';
|
||
var label = 'Connected';
|
||
if (timeAgo) label += ' \u00b7 ' + timeAgo;
|
||
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> ' + label;
|
||
statusEl.classList.add('ih-card-connection-ok');
|
||
statusEl.classList.remove('ih-card-connection-fail', 'ih-card-connection-pending');
|
||
} else if (info.status === 'disabled') {
|
||
statusEl.setAttribute('data-connection', 'disabled');
|
||
statusEl.innerHTML = '<i class="fas fa-minus-circle"></i> Disabled';
|
||
statusEl.classList.remove('ih-card-connection-ok', 'ih-card-connection-fail', 'ih-card-connection-pending');
|
||
} else {
|
||
statusEl.setAttribute('data-connection', 'error');
|
||
statusEl.innerHTML = '<i class="fas fa-times-circle"></i> Failed';
|
||
statusEl.classList.add('ih-card-connection-fail');
|
||
statusEl.classList.remove('ih-card-connection-ok', 'ih-card-connection-pending');
|
||
}
|
||
});
|
||
})
|
||
.catch(function() {
|
||
// On error fetching status, leave cards as pending
|
||
});
|
||
}
|
||
|
||
function _timeAgo(utcStr) {
|
||
try {
|
||
var checked = new Date(utcStr + 'Z');
|
||
var now = new Date();
|
||
var diffMs = now - checked;
|
||
if (diffMs < 0) return '';
|
||
var mins = Math.floor(diffMs / 60000);
|
||
if (mins < 1) return 'just now';
|
||
if (mins < 60) return mins + 'm ago';
|
||
var hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return hrs + 'h ago';
|
||
return Math.floor(hrs / 24) + 'd ago';
|
||
} catch(e) { return ''; }
|
||
}
|
||
|
||
function _getPresetLabel(preset) {
|
||
if (!preset || preset === 'manual') return 'Custom';
|
||
for (var i = 0; i < _presets.length; i++) {
|
||
if (_presets[i].key === preset) return _presets[i].name;
|
||
}
|
||
return preset;
|
||
}
|
||
|
||
// ── Editor (full page) ─────────────────────────────────────────────
|
||
|
||
function _openEditor(existingIdx) {
|
||
_editingId = existingIdx ? existingIdx.id : null;
|
||
|
||
var breadcrumb = document.getElementById('ih-editor-breadcrumb-name');
|
||
if (breadcrumb) breadcrumb.textContent = _editingId ? 'Edit Indexer' : 'Add Indexer';
|
||
|
||
var presetSel = document.getElementById('ih-form-preset');
|
||
var nameEl = document.getElementById('ih-form-name');
|
||
var urlEl = document.getElementById('ih-form-url');
|
||
var apiPathEl = document.getElementById('ih-form-api-path');
|
||
var apiKeyEl = document.getElementById('ih-form-api-key');
|
||
var priorityEl = document.getElementById('ih-form-priority');
|
||
var protocolEl = document.getElementById('ih-form-protocol');
|
||
|
||
if (existingIdx) {
|
||
if (presetSel) { presetSel.value = existingIdx.preset || 'manual'; presetSel.disabled = true; }
|
||
if (nameEl) nameEl.value = existingIdx.name || '';
|
||
if (urlEl) { urlEl.value = existingIdx.url || ''; urlEl.readOnly = existingIdx.preset !== 'manual'; }
|
||
if (apiPathEl) { apiPathEl.value = existingIdx.api_path || '/api'; apiPathEl.readOnly = existingIdx.preset !== 'manual'; }
|
||
if (apiKeyEl) apiKeyEl.value = '';
|
||
if (apiKeyEl) apiKeyEl.placeholder = existingIdx.api_key_last4 ? 'Leave blank to keep (\u2022\u2022\u2022\u2022' + existingIdx.api_key_last4 + ')' : 'Enter API key';
|
||
if (priorityEl) priorityEl.value = existingIdx.priority || 50;
|
||
if (protocolEl) protocolEl.value = existingIdx.protocol || 'usenet';
|
||
} else {
|
||
if (presetSel) { presetSel.value = 'manual'; presetSel.disabled = false; }
|
||
if (nameEl) nameEl.value = '';
|
||
if (urlEl) { urlEl.value = ''; urlEl.readOnly = false; }
|
||
if (apiPathEl) { apiPathEl.value = '/api'; apiPathEl.readOnly = false; }
|
||
if (apiKeyEl) { apiKeyEl.value = ''; apiKeyEl.placeholder = 'Enter API key'; }
|
||
if (priorityEl) priorityEl.value = 50;
|
||
if (protocolEl) protocolEl.value = 'usenet';
|
||
}
|
||
|
||
_showEditorView();
|
||
|
||
// Auto-test connection when URL or API key changes
|
||
var statusContainer = document.getElementById('ih-connection-status-container');
|
||
if (statusContainer) statusContainer.style.display = 'flex';
|
||
if (!window._ihConnectionListenersBound) {
|
||
window._ihConnectionListenersBound = true;
|
||
var urlEl2 = document.getElementById('ih-form-url');
|
||
var apiPathEl2 = document.getElementById('ih-form-api-path');
|
||
var apiKeyEl2 = document.getElementById('ih-form-api-key');
|
||
var debounceTimer;
|
||
var runStatus = function() {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(function() { _updateConnectionStatusFromForm(); }, 500);
|
||
};
|
||
if (urlEl2) { urlEl2.addEventListener('input', runStatus); urlEl2.addEventListener('blur', runStatus); }
|
||
if (apiPathEl2) { apiPathEl2.addEventListener('input', runStatus); apiPathEl2.addEventListener('blur', runStatus); }
|
||
if (apiKeyEl2) { apiKeyEl2.addEventListener('input', runStatus); apiKeyEl2.addEventListener('blur', runStatus); }
|
||
}
|
||
setTimeout(function() { _updateConnectionStatusFromForm(); }, 100);
|
||
}
|
||
|
||
function _updateConnectionStatusFromForm() {
|
||
var container = document.getElementById('ih-connection-status-container');
|
||
if (!container) return;
|
||
var urlEl = document.getElementById('ih-form-url');
|
||
var apiPathEl = document.getElementById('ih-form-api-path');
|
||
var apiKeyEl = document.getElementById('ih-form-api-key');
|
||
var url = urlEl ? urlEl.value.trim() : '';
|
||
var apiPath = apiPathEl ? (apiPathEl.value.trim() || '/api') : '/api';
|
||
var apiKey = apiKeyEl ? apiKeyEl.value.trim() : '';
|
||
var hasSavedKey = _editingId && _indexers.length;
|
||
if (hasSavedKey) {
|
||
var existing = null;
|
||
_indexers.forEach(function(i) { if (i.id === _editingId) existing = i; });
|
||
hasSavedKey = !!(existing && existing.api_key_last4);
|
||
}
|
||
if (url.length <= 10 && apiKey.length < 10) {
|
||
container.innerHTML = '<div class="connection-status" style="background: rgba(148,163,184,0.1); color: #94a3b8; border: 1px solid rgba(148,163,184,0.2);"><i class="fas fa-info-circle"></i><span>Enter URL and API Key</span></div>';
|
||
return;
|
||
}
|
||
if (url.length <= 10) {
|
||
container.innerHTML = '<div class="connection-status" style="background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.2);"><i class="fas fa-exclamation-triangle"></i><span>Missing URL</span></div>';
|
||
return;
|
||
}
|
||
if (apiKey.length < 10 && !hasSavedKey) {
|
||
container.innerHTML = '<div class="connection-status" style="background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.2);"><i class="fas fa-exclamation-triangle"></i><span>Missing API Key</span></div>';
|
||
return;
|
||
}
|
||
if (apiKey.length < 10 && hasSavedKey) {
|
||
container.innerHTML = '<div class="connection-status" style="background: rgba(148,163,184,0.1); color: #94a3b8; border: 1px solid rgba(148,163,184,0.2);"><i class="fas fa-check-circle"></i><span>API key saved. Leave blank to keep.</span></div>';
|
||
return;
|
||
}
|
||
container.innerHTML = '<div class="connection-status checking"><i class="fas fa-spinner fa-spin"></i><span>Checking...</span></div>';
|
||
var presetEl = document.getElementById('ih-form-preset');
|
||
var preset = presetEl ? presetEl.value : 'manual';
|
||
var body = { preset: preset, url: url, api_path: apiPath, api_key: apiKey };
|
||
fetch('./api/indexer-hunt/validate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.valid) {
|
||
var msg = 'Connected';
|
||
if (data.response_time_ms != null) msg += ' (' + data.response_time_ms + 'ms)';
|
||
container.innerHTML = '<div class="connection-status success"><i class="fas fa-check-circle"></i><span>' + _esc(msg) + '</span></div>';
|
||
} else {
|
||
container.innerHTML = '<div class="connection-status error"><i class="fas fa-times-circle"></i><span>' + _esc(data.message || 'Connection failed') + '</span></div>';
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
container.innerHTML = '<div class="connection-status error"><i class="fas fa-times-circle"></i><span>' + _esc(String(err && err.message ? err.message : 'Connection failed')) + '</span></div>';
|
||
});
|
||
}
|
||
|
||
function _onPresetChange() {
|
||
var sel = document.getElementById('ih-form-preset');
|
||
var preset = sel ? sel.value : 'manual';
|
||
var isManual = preset === 'manual';
|
||
|
||
var nameEl = document.getElementById('ih-form-name');
|
||
var urlEl = document.getElementById('ih-form-url');
|
||
var apiPathEl = document.getElementById('ih-form-api-path');
|
||
|
||
if (!isManual) {
|
||
var p = null;
|
||
_presets.forEach(function(pr) { if (pr.key === preset) p = pr; });
|
||
if (p) {
|
||
if (nameEl) nameEl.value = p.name;
|
||
if (urlEl) urlEl.value = p.url;
|
||
if (apiPathEl) apiPathEl.value = p.api_path || '/api';
|
||
}
|
||
}
|
||
if (urlEl) urlEl.readOnly = !isManual;
|
||
if (apiPathEl) apiPathEl.readOnly = !isManual;
|
||
}
|
||
|
||
function _saveForm() {
|
||
var nameEl = document.getElementById('ih-form-name');
|
||
var presetEl = document.getElementById('ih-form-preset');
|
||
var urlEl = document.getElementById('ih-form-url');
|
||
var apiPathEl = document.getElementById('ih-form-api-path');
|
||
var apiKeyEl = document.getElementById('ih-form-api-key');
|
||
var priorityEl = document.getElementById('ih-form-priority');
|
||
var protocolEl = document.getElementById('ih-form-protocol');
|
||
|
||
var body = {
|
||
name: (nameEl ? nameEl.value : '').trim(),
|
||
preset: presetEl ? presetEl.value : 'manual',
|
||
url: (urlEl ? urlEl.value : '').trim(),
|
||
api_path: (apiPathEl ? apiPathEl.value : '/api').trim(),
|
||
api_key: (apiKeyEl ? apiKeyEl.value : '').trim(),
|
||
priority: parseInt(priorityEl ? priorityEl.value : '50', 10) || 50,
|
||
enabled: true,
|
||
protocol: protocolEl ? protocolEl.value : 'usenet',
|
||
};
|
||
|
||
if (!body.name) {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('Name is required.', 'error');
|
||
return;
|
||
}
|
||
|
||
var method = _editingId ? 'PUT' : 'POST';
|
||
var url = _editingId ? './api/indexer-hunt/indexers/' + _editingId : './api/indexer-hunt/indexers';
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
var msg = _editingId ? 'Indexer updated.' : 'Indexer added.';
|
||
if (data.linked_instances_updated > 0) {
|
||
msg += ' Updated in ' + data.linked_instances_updated + ' Movie Hunt instance(s).';
|
||
}
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(msg, 'success');
|
||
var searchInput = document.getElementById('ih-search-input');
|
||
if (searchInput) searchInput.value = '';
|
||
_loadIndexers();
|
||
_showListView();
|
||
} else {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(data.error || 'Failed to save.', 'error');
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('Error: ' + err, 'error');
|
||
});
|
||
}
|
||
|
||
// ── Public actions ────────────────────────────────────────────────
|
||
|
||
IH.editIndexer = function(id) {
|
||
var idx = null;
|
||
_indexers.forEach(function(i) { if (i.id === id) idx = i; });
|
||
if (idx) _openEditor(idx);
|
||
};
|
||
|
||
IH.testIndexer = function(id) {
|
||
var card = document.querySelector('.ih-card[data-id="' + id + '"]');
|
||
var statusEl = card ? card.querySelector('.ih-card-connection-status') : null;
|
||
if (statusEl) {
|
||
statusEl.setAttribute('data-connection', 'checking');
|
||
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...';
|
||
statusEl.classList.remove('ih-card-connection-ok', 'ih-card-connection-fail', 'ih-card-connection-pending');
|
||
}
|
||
fetch('./api/indexer-hunt/indexers/' + id + '/test', { method: 'POST' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.valid) {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('Connection OK (' + (data.response_time_ms || 0) + 'ms)', 'success');
|
||
if (statusEl) {
|
||
statusEl.setAttribute('data-connection', 'connected');
|
||
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> Connected \u00b7 just now';
|
||
statusEl.classList.add('ih-card-connection-ok');
|
||
statusEl.classList.remove('ih-card-connection-fail', 'ih-card-connection-pending');
|
||
}
|
||
} else {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(data.message || 'Test failed.', 'error');
|
||
if (statusEl) {
|
||
statusEl.setAttribute('data-connection', 'error');
|
||
statusEl.innerHTML = '<i class="fas fa-times-circle"></i> Failed';
|
||
statusEl.classList.add('ih-card-connection-fail');
|
||
statusEl.classList.remove('ih-card-connection-ok', 'ih-card-connection-pending');
|
||
}
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('Error: ' + err, 'error');
|
||
});
|
||
};
|
||
|
||
IH.deleteIndexer = function(id, name) {
|
||
fetch('./api/indexer-hunt/linked-instances/' + id)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var linked = data.linked || [];
|
||
var msg = 'Are you sure you want to delete "' + name + '"?';
|
||
if (linked.length > 0) {
|
||
msg += '\n\nThis will also remove it from ' + linked.length + ' linked instance(s).';
|
||
}
|
||
window.HuntarrConfirm.show({
|
||
title: 'Delete Indexer',
|
||
message: msg,
|
||
confirmLabel: 'Delete',
|
||
onConfirm: function() {
|
||
fetch('./api/indexer-hunt/indexers/' + id, { method: 'DELETE' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(res) {
|
||
if (res.success) {
|
||
_loadIndexers();
|
||
var notice = '"' + name + '" deleted.';
|
||
if (res.instances_cleaned > 0) {
|
||
notice += ' Removed from ' + res.instances_cleaned + ' instance(s).';
|
||
}
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(notice, 'success');
|
||
_refreshIndexerInstanceStatus();
|
||
} else {
|
||
if (window.huntarrUI) window.huntarrUI.showNotification(res.error || 'Delete failed.', 'error');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
function _esc(s) {
|
||
if (!s) return '';
|
||
var d = document.createElement('div');
|
||
d.appendChild(document.createTextNode(s));
|
||
return d.innerHTML;
|
||
}
|
||
|
||
document.addEventListener('huntarr:instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt') {
|
||
IH.init();
|
||
}
|
||
});
|
||
document.addEventListener('huntarr:tv-hunt-instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt') {
|
||
IH.init();
|
||
}
|
||
});
|
||
|
||
})();
|
||
|
||
|
||
/* === modules/features/indexer-hunt-home.js === */
|
||
/**
|
||
* Indexer Hunt — Home Page Card
|
||
* Shows indexer list + aggregate statistics on the Home dashboard.
|
||
* Only visible when at least one Indexer Hunt indexer is configured.
|
||
* Mirrors the Prowlarr home card design exactly.
|
||
*/
|
||
window.HuntarrIndexerHuntHome = {
|
||
_pollInterval: null,
|
||
|
||
/* ── Bootstrap ─────────────────────────────────────────────── */
|
||
setup: function() {
|
||
this.load();
|
||
|
||
// Refresh every 5 minutes (same cadence as Prowlarr stats)
|
||
if (!this._pollInterval) {
|
||
var self = this;
|
||
this._pollInterval = setInterval(function() {
|
||
if (window.huntarrUI && window.huntarrUI.currentSection === 'home') {
|
||
self.load();
|
||
}
|
||
}, 5 * 60 * 1000);
|
||
}
|
||
},
|
||
|
||
/* ── Main loader ───────────────────────────────────────────── */
|
||
load: function() {
|
||
var card = document.getElementById('indexerHuntStatusCard');
|
||
if (!card) return;
|
||
if (window.huntarrUI && window.huntarrUI._enableMediaHunt === false) {
|
||
card.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
var self = this;
|
||
|
||
// 1. Fetch indexers list — also tells us whether the card should show
|
||
HuntarrUtils.fetchWithTimeout('./api/indexer-hunt/indexers')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var indexers = data.indexers || [];
|
||
if (indexers.length === 0) {
|
||
card.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
card.style.display = 'block';
|
||
|
||
// Connection badge
|
||
var badge = document.getElementById('ihHomeConnectionStatus');
|
||
if (badge) {
|
||
var enabledCount = indexers.filter(function(i) { return i.enabled !== false; }).length;
|
||
badge.textContent = '🟢 ' + enabledCount + ' Indexer' + (enabledCount !== 1 ? 's' : '') + ' Active';
|
||
badge.className = 'status-badge connected';
|
||
}
|
||
|
||
// Render indexer list (left sub-card)
|
||
self._renderIndexerList(indexers);
|
||
|
||
// 2. Fetch aggregate stats (right sub-card)
|
||
self._loadStats();
|
||
})
|
||
.catch(function() {
|
||
card.style.display = 'none';
|
||
});
|
||
},
|
||
|
||
/* ── Left sub-card: indexer list ───────────────────────────── */
|
||
_renderIndexerList: function(indexers) {
|
||
var list = document.getElementById('ih-home-indexers-list');
|
||
if (!list) return;
|
||
|
||
if (!indexers || indexers.length === 0) {
|
||
list.innerHTML = '<div class="loading-text">No indexers configured</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort alphabetically
|
||
indexers.sort(function(a, b) {
|
||
var na = (a.name || '').toLowerCase();
|
||
var nb = (b.name || '').toLowerCase();
|
||
return na < nb ? -1 : na > nb ? 1 : 0;
|
||
});
|
||
|
||
var html = indexers.map(function(idx) {
|
||
var enabled = idx.enabled !== false;
|
||
var statusClass = enabled ? 'active' : 'failed';
|
||
var statusText = enabled ? 'Active' : 'Disabled';
|
||
var displayName = (idx.name || 'Unnamed').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
return '<div class="indexer-item">' +
|
||
'<span class="indexer-name">' + displayName + '</span>' +
|
||
'<span class="indexer-status ' + statusClass + '">' + statusText + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
list.innerHTML = html;
|
||
},
|
||
|
||
/* ── Right sub-card: aggregate stats ──────────────────────── */
|
||
_loadStats: function() {
|
||
var content = document.getElementById('ih-home-statistics-content');
|
||
if (!content) return;
|
||
|
||
var fmt = function(n) {
|
||
var v = Number(n || 0);
|
||
return Number.isFinite(v) ? String(Math.round(v)) : '0';
|
||
};
|
||
|
||
HuntarrUtils.fetchWithTimeout('./api/indexer-hunt/stats')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(stats) {
|
||
var queries = fmt(stats.total_queries);
|
||
var grabs = fmt(stats.total_grabs);
|
||
var failures = fmt(stats.total_failures);
|
||
var avgMs = Number(stats.avg_response_ms || 0);
|
||
var failRate = Number(stats.failure_rate || 0);
|
||
|
||
content.innerHTML =
|
||
'<div class="stat-card">' +
|
||
'<div class="stat-label">QUERIES (24H)</div>' +
|
||
'<div class="stat-value success">' + queries + '</div>' +
|
||
'</div>' +
|
||
'<div class="stat-card">' +
|
||
'<div class="stat-label">GRABS (24H)</div>' +
|
||
'<div class="stat-value success">' + grabs + '</div>' +
|
||
'</div>' +
|
||
'<div class="stat-card">' +
|
||
'<div class="stat-label">AVG RESPONSE</div>' +
|
||
'<div class="stat-value success">' + (avgMs > 0 ? avgMs.toFixed(0) + 'ms' : 'N/A') + '</div>' +
|
||
'</div>' +
|
||
'<div class="stat-card">' +
|
||
'<div class="stat-label">FAILURE RATE</div>' +
|
||
'<div class="stat-value' + (failRate > 10 ? ' error' : ' success') + '">' + failRate.toFixed(1) + '%</div>' +
|
||
'</div>' +
|
||
'<div class="stat-card">' +
|
||
'<div class="stat-label">FAILURES (24H)</div>' +
|
||
'<div class="stat-value' + (Number(failures) > 0 ? ' error' : ' success') + '">' + failures + '</div>' +
|
||
'</div>';
|
||
})
|
||
.catch(function() {
|
||
content.innerHTML = '<div class="loading-text" style="color: #ef4444;">Failed to load stats</div>';
|
||
});
|
||
}
|
||
};
|
||
|
||
|
||
/* === modules/features/indexer-hunt-stats.js === */
|
||
/**
|
||
* Indexer Hunt — Stats page module.
|
||
* Displays aggregate and per-indexer statistics.
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
var Stats = window.IndexerHuntStats = {};
|
||
|
||
Stats.init = function() {
|
||
var noInstEl = document.getElementById('indexer-hunt-stats-no-instances');
|
||
var wrapperEl = document.getElementById('indexer-hunt-stats-content-wrapper');
|
||
var noIdxEl = document.getElementById('indexer-hunt-stats-no-indexers');
|
||
var noCliEl = document.getElementById('indexer-hunt-stats-no-clients');
|
||
Promise.all([
|
||
fetch('./api/movie-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/tv-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/indexer-hunt/indexers', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/movie-hunt/has-clients', { cache: 'no-store' }).then(function(r) { return r.json(); })
|
||
]).then(function(results) {
|
||
var movieCount = (results[0].instances || []).length;
|
||
var tvCount = (results[1].instances || []).length;
|
||
var indexerCount = (results[2].indexers || []).length;
|
||
var hasClients = results[3].has_clients === true;
|
||
if (movieCount === 0 && tvCount === 0) {
|
||
if (noInstEl) noInstEl.style.display = '';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (indexerCount === 0) {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = '';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (!hasClients) {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = '';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_loadAggregateStats();
|
||
_loadPerIndexerStats();
|
||
}).catch(function() {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_loadAggregateStats();
|
||
_loadPerIndexerStats();
|
||
});
|
||
};
|
||
|
||
function _loadAggregateStats() {
|
||
fetch('./api/indexer-hunt/stats')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
_setVal('ih-stat-queries', data.total_queries || 0);
|
||
_setVal('ih-stat-grabs', data.total_grabs || 0);
|
||
_setVal('ih-stat-failures', data.total_failures || 0);
|
||
var respEl = document.getElementById('ih-stat-response');
|
||
if (respEl) respEl.innerHTML = (data.avg_response_ms || 0) + '<span class="ih-stat-unit">ms</span>';
|
||
var rateEl = document.getElementById('ih-stat-failure-rate');
|
||
if (rateEl) rateEl.innerHTML = (data.failure_rate || 0) + '<span class="ih-stat-unit">%</span>';
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[IndexerHuntStats] Aggregate load error:', err);
|
||
});
|
||
}
|
||
|
||
function _loadPerIndexerStats() {
|
||
fetch('./api/indexer-hunt/stats/per-indexer')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var indexers = data.indexers || [];
|
||
var tbody = document.getElementById('ih-stats-table-body');
|
||
var tableWrap = document.getElementById('ih-stats-table-wrap');
|
||
var empty = document.getElementById('ih-stats-empty');
|
||
if (!tbody) return;
|
||
|
||
if (indexers.length === 0) {
|
||
if (tableWrap) tableWrap.style.display = 'none';
|
||
if (empty) empty.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
if (tableWrap) tableWrap.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
var html = '';
|
||
indexers.forEach(function(idx) {
|
||
var statusHtml = idx.enabled
|
||
? '<span class="ih-card-status enabled" style="font-size:0.7rem;"><i class="fas fa-check-circle"></i> Enabled</span>'
|
||
: '<span class="ih-card-status disabled" style="font-size:0.7rem;"><i class="fas fa-minus-circle"></i> Disabled</span>';
|
||
html += '<tr>'
|
||
+ '<td><strong>' + _esc(idx.name) + '</strong></td>'
|
||
+ '<td><span class="ih-card-priority-badge">' + (idx.priority || 50) + '</span></td>'
|
||
+ '<td>' + (idx.searches || 0) + '</td>'
|
||
+ '<td>' + (idx.grabs || 0) + '</td>'
|
||
+ '<td>' + (idx.failures || 0) + '</td>'
|
||
+ '<td>' + (idx.avg_response_ms || 0) + 'ms</td>'
|
||
+ '<td>' + (idx.failure_rate || 0) + '%</td>'
|
||
+ '<td>' + statusHtml + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
tbody.innerHTML = html;
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[IndexerHuntStats] Per-indexer load error:', err);
|
||
});
|
||
}
|
||
|
||
function _setVal(id, val) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.textContent = val;
|
||
}
|
||
|
||
function _esc(s) {
|
||
if (!s) return '';
|
||
var d = document.createElement('div');
|
||
d.appendChild(document.createTextNode(s));
|
||
return d.innerHTML;
|
||
}
|
||
|
||
document.addEventListener('huntarr:instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-stats-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt-stats') {
|
||
Stats.init();
|
||
}
|
||
});
|
||
document.addEventListener('huntarr:tv-hunt-instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-stats-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt-stats') {
|
||
Stats.init();
|
||
}
|
||
});
|
||
|
||
})();
|
||
|
||
|
||
/* === modules/features/indexer-hunt-history.js === */
|
||
/**
|
||
* Indexer Hunt — History page module.
|
||
* Displays paginated event history with filters.
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
var History = window.IndexerHuntHistory = {};
|
||
var _currentPage = 1;
|
||
var _totalPages = 1;
|
||
var _initialized = false;
|
||
|
||
History.init = function() {
|
||
if (!_initialized) {
|
||
_bindEvents();
|
||
_loadIndexerFilter();
|
||
_initialized = true;
|
||
}
|
||
var noInstEl = document.getElementById('indexer-hunt-history-no-instances');
|
||
var wrapperEl = document.getElementById('indexer-hunt-history-content-wrapper');
|
||
var noIdxEl = document.getElementById('indexer-hunt-history-no-indexers');
|
||
var noCliEl = document.getElementById('indexer-hunt-history-no-clients');
|
||
Promise.all([
|
||
fetch('./api/movie-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/tv-hunt/instances', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/indexer-hunt/indexers', { cache: 'no-store' }).then(function(r) { return r.json(); }),
|
||
fetch('./api/movie-hunt/has-clients', { cache: 'no-store' }).then(function(r) { return r.json(); })
|
||
]).then(function(results) {
|
||
var movieCount = (results[0].instances || []).length;
|
||
var tvCount = (results[1].instances || []).length;
|
||
var indexerCount = (results[2].indexers || []).length;
|
||
var hasClients = results[3].has_clients === true;
|
||
if (movieCount === 0 && tvCount === 0) {
|
||
if (noInstEl) noInstEl.style.display = '';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (indexerCount === 0) {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = '';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (!hasClients) {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = '';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
return;
|
||
}
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_currentPage = 1;
|
||
_loadHistory();
|
||
}).catch(function() {
|
||
if (noInstEl) noInstEl.style.display = 'none';
|
||
if (noIdxEl) noIdxEl.style.display = 'none';
|
||
if (noCliEl) noCliEl.style.display = 'none';
|
||
if (wrapperEl) wrapperEl.style.display = '';
|
||
_currentPage = 1;
|
||
_loadHistory();
|
||
});
|
||
};
|
||
|
||
function _bindEvents() {
|
||
var typeFilter = document.getElementById('ih-history-type-filter');
|
||
if (typeFilter) typeFilter.addEventListener('change', function() { _currentPage = 1; _loadHistory(); });
|
||
|
||
var indexerFilter = document.getElementById('ih-history-indexer-filter');
|
||
if (indexerFilter) indexerFilter.addEventListener('change', function() { _currentPage = 1; _loadHistory(); });
|
||
|
||
var prevBtn = document.getElementById('ih-history-prev-btn');
|
||
if (prevBtn) prevBtn.addEventListener('click', function() {
|
||
if (_currentPage > 1) { _currentPage--; _loadHistory(); }
|
||
});
|
||
|
||
var nextBtn = document.getElementById('ih-history-next-btn');
|
||
if (nextBtn) nextBtn.addEventListener('click', function() {
|
||
if (_currentPage < _totalPages) { _currentPage++; _loadHistory(); }
|
||
});
|
||
|
||
var clearBtn = document.getElementById('ih-history-clear-btn');
|
||
if (clearBtn) clearBtn.addEventListener('click', function() {
|
||
window.HuntarrConfirm.show({
|
||
title: 'Clear History',
|
||
message: 'Are you sure you want to clear all Index Master history and stats? This cannot be undone.',
|
||
confirmLabel: 'Clear',
|
||
onConfirm: function() {
|
||
fetch('./api/indexer-hunt/history', { method: 'DELETE' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
_currentPage = 1;
|
||
_loadHistory();
|
||
if (window.huntarrUI) window.huntarrUI.showNotification('History cleared.', 'success');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _loadIndexerFilter() {
|
||
fetch('./api/indexer-hunt/indexers')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var sel = document.getElementById('ih-history-indexer-filter');
|
||
if (!sel) return;
|
||
var firstOpt = sel.querySelector('option[value=""]');
|
||
sel.innerHTML = '';
|
||
if (firstOpt) sel.appendChild(firstOpt);
|
||
else {
|
||
var opt = document.createElement('option');
|
||
opt.value = '';
|
||
opt.textContent = 'All Indexers';
|
||
sel.appendChild(opt);
|
||
}
|
||
(data.indexers || []).forEach(function(idx) {
|
||
var opt = document.createElement('option');
|
||
opt.value = idx.id;
|
||
opt.textContent = idx.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _loadHistory() {
|
||
var typeFilter = document.getElementById('ih-history-type-filter');
|
||
var indexerFilter = document.getElementById('ih-history-indexer-filter');
|
||
var eventType = typeFilter ? typeFilter.value : '';
|
||
var indexerId = indexerFilter ? indexerFilter.value : '';
|
||
|
||
var params = 'page=' + _currentPage + '&page_size=50';
|
||
if (eventType) params += '&event_type=' + encodeURIComponent(eventType);
|
||
if (indexerId) params += '&indexer_id=' + encodeURIComponent(indexerId);
|
||
|
||
fetch('./api/indexer-hunt/history?' + params)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var items = data.items || [];
|
||
_totalPages = data.total_pages || 1;
|
||
_currentPage = data.page || 1;
|
||
_renderTable(items);
|
||
_updatePagination(data.total || 0);
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[IndexerHuntHistory] Load error:', err);
|
||
});
|
||
}
|
||
|
||
function _renderTable(items) {
|
||
var tbody = document.getElementById('ih-history-table-body');
|
||
var tableWrap = document.getElementById('ih-history-table-wrap');
|
||
var empty = document.getElementById('ih-history-empty');
|
||
if (!tbody) return;
|
||
|
||
if (items.length === 0) {
|
||
tbody.innerHTML = '';
|
||
if (tableWrap) tableWrap.style.display = 'none';
|
||
if (empty) empty.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
if (tableWrap) tableWrap.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
var html = '';
|
||
items.forEach(function(ev) {
|
||
var date = ev.created_at || '';
|
||
try {
|
||
var d = new Date(date);
|
||
if (!isNaN(d.getTime())) {
|
||
date = d.toLocaleString();
|
||
}
|
||
} catch(e) {}
|
||
|
||
var rawType = ev.event_type || 'unknown';
|
||
var typeLabels = { search: 'Search', grab: 'Grab', failure: 'Failure', test: 'Connection Test', health_check: 'Hourly Check' };
|
||
var typeLabel = typeLabels[rawType] || rawType;
|
||
var typeClass = 'ih-event-' + rawType;
|
||
var typeBadge = '<span class="ih-event-badge ' + typeClass + '">' + _esc(typeLabel) + '</span>';
|
||
var statusIcon = ev.success
|
||
? '<i class="fas fa-check-circle" style="color: #10b981;"></i>'
|
||
: '<i class="fas fa-times-circle" style="color: #ef4444;"></i>';
|
||
|
||
html += '<tr>'
|
||
+ '<td style="white-space: nowrap; font-size: 0.85rem; color: #94a3b8;">' + _esc(date) + '</td>'
|
||
+ '<td>' + typeBadge + '</td>'
|
||
+ '<td>' + _esc(ev.indexer_name || '\u2014') + '</td>'
|
||
+ '<td>' + _esc(ev.query || '\u2014') + '</td>'
|
||
+ '<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">'
|
||
+ (ev.result_title
|
||
? '<span title="' + _esc(ev.result_title).replace(/"/g, '"') + '" style="cursor: default;">'
|
||
+ (rawType === 'grab' ? 'Fetched' : 'Found')
|
||
+ '</span>'
|
||
: '\u2014')
|
||
+ '</td>'
|
||
+ '<td>' + (ev.response_time_ms || 0) + 'ms</td>'
|
||
+ '<td>' + statusIcon + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function _updatePagination(total) {
|
||
var pagination = document.getElementById('ih-history-pagination');
|
||
var pageInfo = document.getElementById('ih-history-page-info');
|
||
var prevBtn = document.getElementById('ih-history-prev-btn');
|
||
var nextBtn = document.getElementById('ih-history-next-btn');
|
||
|
||
if (total <= 50) {
|
||
if (pagination) pagination.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
if (pagination) pagination.style.display = 'flex';
|
||
if (pageInfo) pageInfo.textContent = 'Page ' + _currentPage + ' of ' + _totalPages;
|
||
if (prevBtn) prevBtn.disabled = _currentPage <= 1;
|
||
if (nextBtn) nextBtn.disabled = _currentPage >= _totalPages;
|
||
}
|
||
|
||
function _esc(s) {
|
||
if (!s) return '';
|
||
var d = document.createElement('div');
|
||
d.appendChild(document.createTextNode(s));
|
||
return d.innerHTML;
|
||
}
|
||
|
||
document.addEventListener('huntarr:instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-history-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt-history') {
|
||
History.init();
|
||
}
|
||
});
|
||
document.addEventListener('huntarr:tv-hunt-instances-changed', function() {
|
||
if (document.getElementById('indexer-hunt-history-content-wrapper') && window.huntarrUI && window.huntarrUI.currentSection === 'indexer-hunt-history') {
|
||
History.init();
|
||
}
|
||
});
|
||
|
||
})();
|
||
|
||
|
||
/* === modules/features/apps/sonarr.js === */
|
||
// Sonarr-specific functionality
|
||
|
||
(function(app) {
|
||
if (!app) {
|
||
console.error("Huntarr App core is not loaded!");
|
||
return;
|
||
}
|
||
|
||
const sonarrModule = {
|
||
elements: {},
|
||
|
||
init: function() {
|
||
// Cache elements specific to Sonarr settings
|
||
this.cacheElements();
|
||
// Setup event listeners specific to Sonarr settings
|
||
this.setupEventListeners();
|
||
// Initial population of the form is handled by app.js
|
||
},
|
||
|
||
cacheElements: function() {
|
||
// Cache elements used by Sonarr settings form
|
||
this.elements.apiUrlInput = document.getElementById('sonarr_api_url');
|
||
this.elements.apiKeyInput = document.getElementById('sonarr_api_key');
|
||
this.elements.huntMissingItemsInput = document.getElementById('sonarr-hunt-missing-items');
|
||
this.elements.huntUpgradeItemsInput = document.getElementById('sonarr-hunt-upgrade-items');
|
||
this.elements.sleepDurationInput = document.getElementById('sonarr_sleep_duration');
|
||
this.elements.sleepDurationHoursSpan = document.getElementById('sonarr_sleep_duration_hours');
|
||
this.elements.monitoredOnlyInput = document.getElementById('sonarr_monitored_only');
|
||
this.elements.skipFutureEpisodesInput = document.getElementById('sonarr_skip_future_episodes');
|
||
this.elements.skipSeriesRefreshInput = document.getElementById('sonarr_skip_series_refresh');
|
||
this.elements.randomMissingInput = document.getElementById('sonarr_random_missing');
|
||
this.elements.randomUpgradesInput = document.getElementById('sonarr_random_upgrades');
|
||
this.elements.debugModeInput = document.getElementById('sonarr_debug_mode');
|
||
this.elements.apiTimeoutInput = document.getElementById('sonarr_api_timeout');
|
||
this.elements.commandWaitDelayInput = document.getElementById('sonarr_command_wait_delay');
|
||
this.elements.commandWaitAttemptsInput = document.getElementById('sonarr_command_wait_attempts');
|
||
this.elements.minimumDownloadQueueSizeInput = document.getElementById('sonarr_minimum_download_queue_size');
|
||
// Add other Sonarr-specific elements if any
|
||
},
|
||
|
||
setupEventListeners: function() {
|
||
// Add event listeners for Sonarr-specific controls if needed
|
||
// Example: If there were unique interactions for Sonarr settings
|
||
// Most change detection is now handled centrally by app.js
|
||
|
||
// Update sleep duration display on input change
|
||
if (this.elements.sleepDurationInput) {
|
||
this.elements.sleepDurationInput.addEventListener('input', () => {
|
||
this.updateSleepDurationDisplay();
|
||
// Central change detection handles the rest
|
||
});
|
||
}
|
||
},
|
||
|
||
updateSleepDurationDisplay: function() {
|
||
// Use the central utility function for updating duration display
|
||
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
|
||
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
|
||
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
|
||
}
|
||
},
|
||
|
||
// REMOVED: loadSettings function (handled by app.js)
|
||
|
||
// REMOVED: checkForChanges function (handled by app.js)
|
||
|
||
// REMOVED: updateSaveButtonState function (handled by app.js)
|
||
|
||
// REMOVED: getSettingsPayload function (handled by app.js)
|
||
|
||
// REMOVED: saveSettings function (handled by app.js)
|
||
|
||
// REMOVED: Overriding of app.saveSettings
|
||
};
|
||
|
||
// Initialize Sonarr module
|
||
sonarrModule.init();
|
||
|
||
// Add the Sonarr module to the app for reference if needed elsewhere
|
||
app.sonarrModule = sonarrModule;
|
||
|
||
})(window.huntarrUI); // Use the new global object name
|
||
|
||
|
||
/* === modules/features/apps/radarr.js === */
|
||
// Radarr-specific functionality
|
||
|
||
(function(app) {
|
||
if (!app) {
|
||
console.error("Huntarr App core is not loaded!");
|
||
return;
|
||
}
|
||
|
||
const radarrModule = {
|
||
elements: {},
|
||
|
||
init: function() {
|
||
console.log('[Radarr Module] Initializing...');
|
||
this.cacheElements();
|
||
this.setupEventListeners();
|
||
},
|
||
|
||
cacheElements: function() {
|
||
// Cache elements specific to the Radarr settings form
|
||
this.elements.apiUrlInput = document.getElementById('radarr_api_url');
|
||
this.elements.apiKeyInput = document.getElementById('radarr_api_key');
|
||
this.elements.huntMissingMoviesInput = document.getElementById('hunt_missing_movies');
|
||
this.elements.huntUpgradeMoviesInput = document.getElementById('hunt_upgrade_movies');
|
||
this.elements.sleepDurationInput = document.getElementById('radarr_sleep_duration');
|
||
this.elements.sleepDurationHoursSpan = document.getElementById('radarr_sleep_duration_hours');
|
||
this.elements.stateResetIntervalInput = document.getElementById('radarr_state_reset_interval_hours');
|
||
this.elements.monitoredOnlyInput = document.getElementById('radarr_monitored_only');
|
||
this.elements.skipFutureReleasesInput = document.getElementById('skip_future_releases'); // Note: ID might be shared
|
||
this.elements.skipMovieRefreshInput = document.getElementById('skip_movie_refresh');
|
||
this.elements.randomMissingInput = document.getElementById('radarr_random_missing');
|
||
this.elements.randomUpgradesInput = document.getElementById('radarr_random_upgrades');
|
||
this.elements.debugModeInput = document.getElementById('radarr_debug_mode');
|
||
this.elements.apiTimeoutInput = document.getElementById('radarr_api_timeout');
|
||
this.elements.commandWaitDelayInput = document.getElementById('radarr_command_wait_delay');
|
||
this.elements.commandWaitAttemptsInput = document.getElementById('radarr_command_wait_attempts');
|
||
this.elements.minimumDownloadQueueSizeInput = document.getElementById('radarr_minimum_download_queue_size');
|
||
// Add any other Radarr-specific elements
|
||
},
|
||
|
||
setupEventListeners: function() {
|
||
// Keep listeners ONLY for elements with specific UI updates beyond simple value changes
|
||
if (this.elements.sleepDurationInput) {
|
||
this.elements.sleepDurationInput.addEventListener('input', () => {
|
||
this.updateSleepDurationDisplay();
|
||
// No need to call checkForChanges here, handled by delegation
|
||
});
|
||
}
|
||
// Remove other input listeners previously used for checkForChanges
|
||
},
|
||
|
||
updateSleepDurationDisplay: function() {
|
||
// This function remains as it updates a specific UI element
|
||
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
|
||
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
|
||
// Assuming app.updateDurationDisplay exists and is accessible
|
||
if (app && typeof app.updateDurationDisplay === 'function') {
|
||
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
|
||
} else {
|
||
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Initialize Radarr module
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (document.getElementById('radarrSettings')) {
|
||
radarrModule.init();
|
||
if (app) {
|
||
app.radarrModule = radarrModule;
|
||
}
|
||
}
|
||
});
|
||
|
||
})(window.huntarrUI); // Pass the global UI object
|
||
|
||
|
||
/* === modules/features/apps/lidarr.js === */
|
||
// Lidarr-specific functionality
|
||
|
||
(function(app) {
|
||
if (!app) {
|
||
console.error("Huntarr App core is not loaded!");
|
||
return;
|
||
}
|
||
|
||
const lidarrModule = {
|
||
elements: {
|
||
apiUrlInput: document.getElementById('lidarr_api_url'),
|
||
apiKeyInput: document.getElementById('lidarr_api_key'),
|
||
connectionTestButton: document.getElementById('test-lidarr-connection'),
|
||
huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
|
||
huntMissingItemsInput: document.getElementById('hunt_missing_items'),
|
||
huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
|
||
sleepDurationInput: document.getElementById('lidarr_sleep_duration'),
|
||
sleepDurationHoursSpan: document.getElementById('lidarr_sleep_duration_hours'),
|
||
stateResetIntervalInput: document.getElementById('lidarr_state_reset_interval_hours'),
|
||
monitoredOnlyInput: document.getElementById('lidarr_monitored_only'),
|
||
skipFutureReleasesInput: document.getElementById('lidarr_skip_future_releases'),
|
||
skipArtistRefreshInput: document.getElementById('skip_artist_refresh'),
|
||
randomMissingInput: document.getElementById('lidarr_random_missing'),
|
||
randomUpgradesInput: document.getElementById('lidarr_random_upgrades'),
|
||
debugModeInput: document.getElementById('lidarr_debug_mode'),
|
||
apiTimeoutInput: document.getElementById('lidarr_api_timeout'),
|
||
commandWaitDelayInput: document.getElementById('lidarr_command_wait_delay'),
|
||
commandWaitAttemptsInput: document.getElementById('lidarr_command_wait_attempts'),
|
||
minimumDownloadQueueSizeInput: document.getElementById('lidarr_minimum_download_queue_size')
|
||
},
|
||
|
||
init: function() {
|
||
console.log('[Lidarr Module] Initializing...');
|
||
// Cache elements specific to the Lidarr settings form
|
||
this.elements = {
|
||
apiUrlInput: document.getElementById('lidarr_api_url'),
|
||
apiKeyInput: document.getElementById('lidarr_api_key'),
|
||
connectionTestButton: document.getElementById('test-lidarr-connection'),
|
||
huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
|
||
huntMissingItemsInput: document.getElementById('hunt_missing_items'),
|
||
huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
|
||
// ...other element references
|
||
};
|
||
|
||
// Add event listeners
|
||
this.addEventListeners();
|
||
},
|
||
|
||
addEventListeners() {
|
||
// Add connection test button click handler
|
||
if (this.elements.connectionTestButton) {
|
||
this.elements.connectionTestButton.addEventListener('click', this.testConnection.bind(this));
|
||
}
|
||
|
||
// Add event listener to update help text when missing mode changes
|
||
if (this.elements.huntMissingModeSelect) {
|
||
this.elements.huntMissingModeSelect.addEventListener('change', this.updateHuntMissingModeHelp.bind(this));
|
||
// Initial update
|
||
this.updateHuntMissingModeHelp();
|
||
}
|
||
},
|
||
|
||
// Update help text based on selected missing mode
|
||
updateHuntMissingModeHelp() {
|
||
const mode = this.elements.huntMissingModeSelect.value;
|
||
const helpText = document.querySelector('#hunt_missing_items + .setting-help');
|
||
|
||
if (helpText) {
|
||
if (mode === 'artist') {
|
||
helpText.textContent = "Number of artists with missing albums to search per cycle (0 to disable)";
|
||
} else if (mode === 'album') {
|
||
helpText.textContent = "Number of specific albums to search per cycle (0 to disable)";
|
||
}
|
||
}
|
||
},
|
||
|
||
updateSleepDurationDisplay: function() {
|
||
// This function remains as it updates a specific UI element
|
||
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
|
||
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
|
||
// Assuming app.updateDurationDisplay exists and is accessible
|
||
if (app && typeof app.updateDurationDisplay === 'function') {
|
||
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
|
||
} else {
|
||
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Initialize Lidarr module when DOM content is loaded and if lidarrSettings exists
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (document.getElementById('lidarrSettings')) {
|
||
lidarrModule.init();
|
||
if (app) {
|
||
app.lidarrModule = lidarrModule;
|
||
}
|
||
}
|
||
});
|
||
|
||
})(window.huntarrUI); // Pass the global UI object
|
||
|
||
|
||
/* === modules/features/apps/readarr.js === */
|
||
// Readarr-specific functionality
|
||
|
||
(function(app) {
|
||
if (!app) {
|
||
console.error("Huntarr App core is not loaded!");
|
||
return;
|
||
}
|
||
|
||
const readarrModule = {
|
||
elements: {},
|
||
|
||
init: function() {
|
||
console.log('[Readarr Module] Initializing...');
|
||
this.cacheElements();
|
||
this.setupEventListeners();
|
||
},
|
||
|
||
cacheElements: function() {
|
||
// Cache elements specific to the Readarr settings form
|
||
this.elements.apiUrlInput = document.getElementById('readarr_api_url');
|
||
this.elements.apiKeyInput = document.getElementById('readarr_api_key');
|
||
this.elements.huntMissingBooksInput = document.getElementById('hunt_missing_books');
|
||
this.elements.huntUpgradeBooksInput = document.getElementById('hunt_upgrade_books');
|
||
this.elements.sleepDurationInput = document.getElementById('readarr_sleep_duration');
|
||
this.elements.sleepDurationHoursSpan = document.getElementById('readarr_sleep_duration_hours');
|
||
this.elements.stateResetIntervalInput = document.getElementById('readarr_state_reset_interval_hours');
|
||
this.elements.monitoredOnlyInput = document.getElementById('readarr_monitored_only');
|
||
this.elements.skipFutureReleasesInput = document.getElementById('readarr_skip_future_releases');
|
||
this.elements.skipAuthorRefreshInput = document.getElementById('skip_author_refresh');
|
||
this.elements.randomMissingInput = document.getElementById('readarr_random_missing');
|
||
this.elements.randomUpgradesInput = document.getElementById('readarr_random_upgrades');
|
||
this.elements.debugModeInput = document.getElementById('readarr_debug_mode');
|
||
this.elements.apiTimeoutInput = document.getElementById('readarr_api_timeout');
|
||
this.elements.commandWaitDelayInput = document.getElementById('readarr_command_wait_delay');
|
||
this.elements.commandWaitAttemptsInput = document.getElementById('readarr_command_wait_attempts');
|
||
this.elements.minimumDownloadQueueSizeInput = document.getElementById('readarr_minimum_download_queue_size');
|
||
// Add any other Readarr-specific elements
|
||
},
|
||
|
||
setupEventListeners: function() {
|
||
// Keep listeners ONLY for elements with specific UI updates beyond simple value changes
|
||
if (this.elements.sleepDurationInput) {
|
||
this.elements.sleepDurationInput.addEventListener('input', () => {
|
||
this.updateSleepDurationDisplay();
|
||
// No need to call checkForChanges here, handled by delegation
|
||
});
|
||
}
|
||
// Remove other input listeners previously used for checkForChanges
|
||
},
|
||
|
||
updateSleepDurationDisplay: function() {
|
||
// This function remains as it updates a specific UI element
|
||
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
|
||
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
|
||
// Assuming app.updateDurationDisplay exists and is accessible
|
||
if (app && typeof app.updateDurationDisplay === 'function') {
|
||
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
|
||
} else {
|
||
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Initialize Readarr module
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (document.getElementById('readarrSettings')) {
|
||
readarrModule.init();
|
||
if (app) {
|
||
app.readarrModule = readarrModule;
|
||
}
|
||
}
|
||
});
|
||
|
||
})(window.huntarrUI); // Pass the global UI object
|
||
|
||
|
||
/* === modules/features/apps/whisparr.js === */
|
||
/**
|
||
* Whisparr.js - Handles Whisparr settings and interactions in the Huntarr UI
|
||
*/
|
||
|
||
document.addEventListener("DOMContentLoaded", function() {
|
||
// Don't call setupWhisparrForm here, app.js will call it when the tab is active
|
||
// setupWhisparrForm();
|
||
// setupWhisparrLogs(); // Assuming logs are handled by the main logs section
|
||
// setupClearProcessedButtons('whisparr'); // Assuming this is handled elsewhere or not needed immediately
|
||
});
|
||
|
||
/**
|
||
* Setup Whisparr settings form and connection test
|
||
* This function is now called by app.js when the Whisparr settings tab is shown.
|
||
*/
|
||
function setupWhisparrForm() {
|
||
// Use querySelector within the active panel to be safe, though IDs should be unique
|
||
const panel = document.getElementById('whisparrSettings');
|
||
if (!panel) {
|
||
console.warn("[whisparr.js] Whisparr settings panel not found.");
|
||
return;
|
||
}
|
||
|
||
const testWhisparrButton = panel.querySelector('#test-whisparr-button');
|
||
const whisparrStatusIndicator = panel.querySelector('#whisparr-connection-status');
|
||
const whisparrVersionDisplay = panel.querySelector('#whisparr-version');
|
||
const apiUrlInput = panel.querySelector('#whisparr_api_url');
|
||
const apiKeyInput = panel.querySelector('#whisparr_api_key');
|
||
|
||
// Check if elements exist and if listener already attached to prevent duplicates
|
||
if (!testWhisparrButton || testWhisparrButton.dataset.listenerAttached === 'true') {
|
||
console.log("[whisparr.js] Test button not found or listener already attached.");
|
||
return;
|
||
}
|
||
console.log("[whisparr.js] Setting up Whisparr form listeners.");
|
||
testWhisparrButton.dataset.listenerAttached = 'true'; // Mark as attached
|
||
|
||
// Test connection button
|
||
testWhisparrButton.addEventListener('click', function() {
|
||
// Temporarily suppress change detection to prevent the unsaved changes dialog
|
||
window._suppressUnsavedChangesDialog = true;
|
||
|
||
const apiUrl = apiUrlInput ? apiUrlInput.value.trim() : '';
|
||
const apiKey = apiKeyInput ? apiKeyInput.value.trim() : '';
|
||
|
||
if (!apiUrl || !apiKey) {
|
||
// Reset suppression flag
|
||
window._suppressUnsavedChangesDialog = false;
|
||
|
||
// Use the main UI notification system if available
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
|
||
huntarrUI.showNotification('Please enter both API URL and API Key for Whisparr', 'error');
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Please enter both API URL and API Key for Whisparr', 'error');
|
||
else alert('Please enter both API URL and API Key for Whisparr');
|
||
}
|
||
return;
|
||
}
|
||
|
||
testWhisparrButton.disabled = true;
|
||
if (whisparrStatusIndicator) {
|
||
whisparrStatusIndicator.className = 'connection-status pending';
|
||
whisparrStatusIndicator.textContent = 'Testing...';
|
||
}
|
||
|
||
// Direct connection test - let the backend handle version checking
|
||
HuntarrUtils.fetchWithTimeout('./api/whisparr/test-connection', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
api_url: apiUrl,
|
||
api_key: apiKey
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (whisparrStatusIndicator) {
|
||
if (data.success) {
|
||
whisparrStatusIndicator.className = 'connection-status success';
|
||
whisparrStatusIndicator.textContent = 'Connected';
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
|
||
huntarrUI.showNotification('Successfully connected to Whisparr V2', 'success');
|
||
}
|
||
getWhisparrVersion(); // Fetch version after successful connection
|
||
} else {
|
||
whisparrStatusIndicator.className = 'connection-status failure';
|
||
whisparrStatusIndicator.textContent = 'Failed';
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
|
||
huntarrUI.showNotification('Connection to Whisparr failed: ' + data.message, 'error');
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
if (whisparrStatusIndicator) {
|
||
whisparrStatusIndicator.className = 'connection-status failure';
|
||
whisparrStatusIndicator.textContent = 'Error';
|
||
}
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
|
||
huntarrUI.showNotification('Error testing Whisparr connection: ' + error, 'error');
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (testWhisparrButton.disabled) {
|
||
testWhisparrButton.disabled = false;
|
||
}
|
||
|
||
// Reset suppression flag after a short delay
|
||
setTimeout(() => {
|
||
window._suppressUnsavedChangesDialog = false;
|
||
}, 500);
|
||
});
|
||
});
|
||
|
||
// Get Whisparr version if connection details are present and version display exists
|
||
// Only perform auto-check if we haven't already fetched the version
|
||
if (apiUrlInput && apiKeyInput && whisparrVersionDisplay &&
|
||
apiUrlInput.value && apiKeyInput.value &&
|
||
(!whisparrVersionDisplay.textContent || whisparrVersionDisplay.textContent === 'Unknown')) {
|
||
|
||
// Set a flag to prevent automatic version checks from triggering unsaved changes
|
||
const wasSettingsChanged = typeof huntarrUI !== 'undefined' ? huntarrUI.settingsChanged : false;
|
||
|
||
getWhisparrVersion();
|
||
|
||
// Restore the original settingsChanged state after the version check
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.settingsChanged !== wasSettingsChanged) {
|
||
setTimeout(() => {
|
||
huntarrUI.settingsChanged = wasSettingsChanged;
|
||
console.log("[whisparr.js] Restored settingsChanged state after version check");
|
||
|
||
// If there are no actual changes, update the save button state
|
||
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
|
||
huntarrUI.updateSaveResetButtonState(false);
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// Function to get Whisparr version
|
||
function getWhisparrVersion() {
|
||
if (!whisparrVersionDisplay) return; // Check if element exists
|
||
|
||
const wasSettingsChanged = typeof huntarrUI !== 'undefined' ? huntarrUI.settingsChanged : false;
|
||
|
||
HuntarrUtils.fetchWithTimeout('./api/whisparr/get-versions')
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch Whisparr version');
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data.success && data.version) {
|
||
// Temporarily store the textContent so we can detect if it actually changes
|
||
const oldContent = whisparrVersionDisplay.textContent;
|
||
const newContent = `v${data.version}`;
|
||
|
||
if (oldContent !== newContent) {
|
||
whisparrVersionDisplay.textContent = newContent; // Prepend 'v'
|
||
|
||
// Restore settings changed state to prevent triggering the dialog
|
||
if (typeof huntarrUI !== 'undefined') {
|
||
setTimeout(() => {
|
||
huntarrUI.settingsChanged = wasSettingsChanged;
|
||
|
||
// If there are no actual changes, update the save button state
|
||
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
|
||
huntarrUI.updateSaveResetButtonState(false);
|
||
}
|
||
}, 50);
|
||
}
|
||
}
|
||
} else {
|
||
whisparrVersionDisplay.textContent = 'Unknown';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
whisparrVersionDisplay.textContent = 'Error';
|
||
console.error('Error fetching Whisparr version:', error);
|
||
})
|
||
.finally(() => {
|
||
// Final safety check to restore settings state
|
||
if (typeof huntarrUI !== 'undefined' && huntarrUI.settingsChanged !== wasSettingsChanged) {
|
||
setTimeout(() => {
|
||
huntarrUI.settingsChanged = wasSettingsChanged;
|
||
// If there are no actual changes, update the save button state
|
||
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
|
||
huntarrUI.updateSaveResetButtonState(false);
|
||
}
|
||
}, 100);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/* === modules/features/apps/eros.js === */
|
||
/**
|
||
* Eros.js - Handles Eros settings and interactions in the Huntarr UI
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Don't call setupErosForm here, app.js will call it when the tab is active
|
||
// setupErosForm();
|
||
// setupErosLogs(); // Assuming logs are handled by the main logs section
|
||
// setupClearProcessedButtons('eros'); // Assuming this is handled elsewhere or not needed immediately
|
||
});
|
||
|
||
/**
|
||
* Setup Eros settings form and connection test
|
||
* This function is now called by app.js when the Eros settings tab is shown.
|
||
*/
|
||
function setupErosForm() {
|
||
console.log("[eros.js] Setting up Eros form...");
|
||
const panel = document.getElementById('erosSettings');
|
||
if (!panel) {
|
||
console.warn("[eros.js] Eros settings panel not found.");
|
||
return;
|
||
}
|
||
|
||
const testErosButton = panel.querySelector('#test-eros-button');
|
||
const erosStatusIndicator = panel.querySelector('#eros-connection-status');
|
||
const erosVersionDisplay = panel.querySelector('#eros-version');
|
||
const apiUrlInput = panel.querySelector('#eros_api_url');
|
||
const apiKeyInput = panel.querySelector('#eros_api_key');
|
||
|
||
// Check if event listener is already attached (prevents duplicate handlers)
|
||
if (!testErosButton || testErosButton.dataset.listenerAttached === 'true') {
|
||
console.log("[eros.js] Test button not found or listener already attached.");
|
||
return;
|
||
}
|
||
console.log("[eros.js] Setting up Eros form listeners.");
|
||
testErosButton.dataset.listenerAttached = 'true'; // Mark as attached
|
||
|
||
// Add event listener for connection test
|
||
testErosButton.addEventListener('click', function() {
|
||
console.log("[eros.js] Testing Eros connection...");
|
||
|
||
// Temporarily suppress change detection to prevent the unsaved changes dialog
|
||
window._suppressUnsavedChangesDialog = true;
|
||
|
||
// Basic validation
|
||
if (!apiUrlInput.value || !apiKeyInput.value) {
|
||
// Reset suppression flag
|
||
window._suppressUnsavedChangesDialog = false;
|
||
|
||
if (typeof huntarrUI !== 'undefined') {
|
||
huntarrUI.showNotification('Please enter both API URL and API Key for Eros', 'error');
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Please enter both API URL and API Key for Eros', 'error');
|
||
else alert('Please enter both API URL and API Key for Eros');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Disable button during test and show pending status
|
||
testErosButton.disabled = true;
|
||
if (erosStatusIndicator) {
|
||
erosStatusIndicator.className = 'connection-status pending';
|
||
erosStatusIndicator.textContent = 'Testing...';
|
||
}
|
||
|
||
// Call API to test connection
|
||
HuntarrUtils.fetchWithTimeout('./api/eros/test-connection', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
api_url: apiUrlInput.value,
|
||
api_key: apiKeyInput.value,
|
||
api_timeout: 30
|
||
})
|
||
}, 30000) // 30 second timeout
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
// Enable the button again
|
||
testErosButton.disabled = false;
|
||
|
||
// Reset suppression flag after a short delay
|
||
setTimeout(() => {
|
||
window._suppressUnsavedChangesDialog = false;
|
||
}, 500);
|
||
|
||
if (erosStatusIndicator) {
|
||
if (data.success) {
|
||
erosStatusIndicator.className = 'connection-status success';
|
||
erosStatusIndicator.textContent = 'Connected';
|
||
if (typeof huntarrUI !== 'undefined') {
|
||
huntarrUI.showNotification('Successfully connected to Eros', 'success');
|
||
}
|
||
getErosVersion(); // Fetch version after successful connection
|
||
} else {
|
||
erosStatusIndicator.className = 'connection-status failure';
|
||
erosStatusIndicator.textContent = 'Failed';
|
||
if (typeof huntarrUI !== 'undefined') {
|
||
huntarrUI.showNotification(data.message || 'Failed to connect to Eros', 'error');
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification(data.message || 'Failed to connect to Eros', 'error');
|
||
else alert(data.message || 'Failed to connect to Eros');
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('[eros.js] Error testing connection:', error);
|
||
testErosButton.disabled = false;
|
||
|
||
// Reset suppression flag
|
||
window._suppressUnsavedChangesDialog = false;
|
||
|
||
if (erosStatusIndicator) {
|
||
erosStatusIndicator.className = 'connection-status failure';
|
||
erosStatusIndicator.textContent = 'Error';
|
||
}
|
||
|
||
if (typeof huntarrUI !== 'undefined') {
|
||
huntarrUI.showNotification('Error testing connection: ' + error.message, 'error');
|
||
} else {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) window.huntarrUI.showNotification('Error testing connection: ' + error.message, 'error');
|
||
else alert('Error testing connection: ' + error.message);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Initialize form state and fetch data
|
||
refreshErosStatusAndVersion();
|
||
}
|
||
|
||
/**
|
||
* Get the Eros software version from the instance.
|
||
* This is separate from the API test.
|
||
*/
|
||
function getErosVersion() {
|
||
const panel = document.getElementById('erosSettings');
|
||
if (!panel) return;
|
||
|
||
const versionDisplay = panel.querySelector('#eros-version');
|
||
if (!versionDisplay) return;
|
||
|
||
// Try to get the API settings from the form
|
||
const apiUrlInput = panel.querySelector('#eros_api_url');
|
||
const apiKeyInput = panel.querySelector('#eros_api_key');
|
||
|
||
if (!apiUrlInput || !apiUrlInput.value || !apiKeyInput || !apiKeyInput.value) {
|
||
versionDisplay.textContent = 'N/A';
|
||
return;
|
||
}
|
||
|
||
// Endpoint to get version info - using the test endpoint since it returns version
|
||
HuntarrUtils.fetchWithTimeout('./api/eros/test-connection', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
api_url: apiUrlInput.value,
|
||
api_key: apiKeyInput.value,
|
||
api_timeout: 10
|
||
})
|
||
}, 10000)
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success && data.version) {
|
||
versionDisplay.textContent = 'v' + data.version;
|
||
} else {
|
||
versionDisplay.textContent = 'Unknown';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('[eros.js] Error fetching version:', error);
|
||
versionDisplay.textContent = 'Error';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Refresh the connection status and version display for Eros.
|
||
*/
|
||
function refreshErosStatusAndVersion() {
|
||
// Try to get current connection status from the server
|
||
HuntarrUtils.fetchWithTimeout('./api/eros/status')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
const panel = document.getElementById('erosSettings');
|
||
if (!panel) return;
|
||
|
||
const statusIndicator = panel.querySelector('#eros-connection-status');
|
||
if (statusIndicator) {
|
||
if (data.connected) {
|
||
statusIndicator.className = 'connection-status success';
|
||
statusIndicator.textContent = 'Connected';
|
||
getErosVersion(); // Try to get version if connected
|
||
} else if (data.configured) {
|
||
statusIndicator.className = 'connection-status failure';
|
||
statusIndicator.textContent = 'Not Connected';
|
||
} else {
|
||
statusIndicator.className = 'connection-status pending';
|
||
statusIndicator.textContent = 'Not Configured';
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('[eros.js] Error checking status:', error);
|
||
});
|
||
}
|
||
|
||
// Mark functions as global if needed by other parts of the application
|
||
window.setupErosForm = setupErosForm;
|
||
window.getErosVersion = getErosVersion;
|
||
window.refreshErosStatusAndVersion = refreshErosStatusAndVersion;
|
||
|
||
|
||
/* === modules/features/apps/swaparr-view.js === */
|
||
// Enhanced Swaparr-specific functionality
|
||
|
||
(function(app) {
|
||
if (!app) {
|
||
console.error("Huntarr App core is not loaded!");
|
||
return;
|
||
}
|
||
|
||
const swaparrModule = {
|
||
elements: {},
|
||
isTableView: true, // Default to table view for Swaparr logs
|
||
hasRenderedAnyContent: false, // Track if we've rendered any content
|
||
|
||
// Store data for display with enhanced structure
|
||
logData: {
|
||
config: {
|
||
platform: '',
|
||
maxStrikes: 3,
|
||
scanInterval: '10m',
|
||
maxDownloadTime: '2h',
|
||
ignoreAboveSize: '25 GB',
|
||
dryRun: false,
|
||
removeFromClient: true
|
||
},
|
||
downloads: [], // Will store download status records
|
||
statistics: { // Enhanced statistics tracking
|
||
session: {
|
||
total_processed: 0,
|
||
strikes_added: 0,
|
||
downloads_removed: 0,
|
||
items_ignored: 0,
|
||
api_calls_made: 0,
|
||
errors_encountered: 0,
|
||
apps_processed: [],
|
||
last_update: null
|
||
},
|
||
apps: {} // Per-app statistics
|
||
},
|
||
rawLogs: [] // Store raw logs for backup display
|
||
},
|
||
|
||
init: function() {
|
||
console.log('[Swaparr Module] Initializing enhanced Swaparr module...');
|
||
this.setupLogProcessor();
|
||
this.setupEventListeners();
|
||
|
||
// Try to load initial statistics
|
||
this.loadStatistics();
|
||
},
|
||
|
||
setupEventListeners: function() {
|
||
// Add a listener for when the log tab changes to Swaparr
|
||
const swaparrTab = document.querySelector('.log-tab[data-app="swaparr"]');
|
||
if (swaparrTab) {
|
||
swaparrTab.addEventListener('click', () => {
|
||
console.log('[Swaparr Module] Swaparr tab clicked');
|
||
// Small delay to ensure everything is ready
|
||
setTimeout(() => {
|
||
this.ensureContentRendered();
|
||
}, 200);
|
||
});
|
||
}
|
||
},
|
||
|
||
setupLogProcessor: function() {
|
||
// Setup a listener for custom event from huntarrUI's log processing
|
||
document.addEventListener('swaparrLogReceived', (event) => {
|
||
console.log('[Swaparr Module] Received log event:', event.detail.logData.substring(0, 100) + '...');
|
||
this.processLogLine(event.detail.logData);
|
||
});
|
||
},
|
||
|
||
loadStatistics: function() {
|
||
// Load statistics from the API
|
||
HuntarrUtils.fetchWithTimeout('./api/swaparr/status')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.session_statistics) {
|
||
this.logData.statistics.session = data.session_statistics;
|
||
}
|
||
if (data.app_statistics) {
|
||
this.logData.statistics.apps = data.app_statistics;
|
||
}
|
||
if (data.settings) {
|
||
this.updateConfigFromSettings(data.settings);
|
||
}
|
||
|
||
console.log('[Swaparr Module] Loaded statistics from API');
|
||
|
||
// Re-render if we're viewing Swaparr
|
||
if (app.currentLogApp === 'swaparr') {
|
||
this.ensureContentRendered();
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.warn('[Swaparr Module] Could not load statistics:', error);
|
||
});
|
||
},
|
||
|
||
updateConfigFromSettings: function(settings) {
|
||
this.logData.config.maxStrikes = settings.max_strikes || 3;
|
||
this.logData.config.maxDownloadTime = settings.max_download_time || '2h';
|
||
this.logData.config.ignoreAboveSize = settings.ignore_above_size || '25GB';
|
||
this.logData.config.dryRun = settings.dry_run || false;
|
||
this.logData.config.removeFromClient = settings.remove_from_client !== false;
|
||
},
|
||
|
||
processLogLine: function(logLine) {
|
||
// Always store raw logs for backup display
|
||
this.logData.rawLogs.push(logLine);
|
||
|
||
// Limit raw logs storage to prevent memory issues
|
||
if (this.logData.rawLogs.length > 500) {
|
||
this.logData.rawLogs.shift();
|
||
}
|
||
|
||
// Process log lines specific to Swaparr
|
||
if (!logLine) return;
|
||
|
||
// Check if this looks like a Swaparr config line and extract information
|
||
if (logLine.includes('Platform:') && logLine.includes('Max strikes:')) {
|
||
this.extractConfigInfo(logLine);
|
||
this.renderConfigPanel();
|
||
return;
|
||
}
|
||
|
||
// Look for enhanced strike-related logs from system
|
||
if (logLine.includes('Added strike') ||
|
||
logLine.includes('Max strikes reached') ||
|
||
logLine.includes('removing download') ||
|
||
logLine.includes('Would have removed') ||
|
||
logLine.includes('Successfully removed') ||
|
||
logLine.includes('Re-removed previously removed') ||
|
||
logLine.includes('Session stats')) {
|
||
|
||
this.processStrikeLog(logLine);
|
||
return;
|
||
}
|
||
|
||
// Check for session statistics updates
|
||
if (logLine.includes('Session stats - Strikes:')) {
|
||
this.extractSessionStats(logLine);
|
||
this.renderStatisticsPanel();
|
||
return;
|
||
}
|
||
|
||
// Check if this is a table header/separator line
|
||
if (logLine.includes('strikes') && logLine.includes('status') && logLine.includes('name') && logLine.includes('size') && logLine.includes('eta')) {
|
||
// This is the header line, we can ignore it or use it to confirm table format
|
||
return;
|
||
}
|
||
|
||
// Try to match enhanced download info line
|
||
const downloadLinePattern = /(\d+\/\d+)\s+(\w+)\s+(.+?)\s+(\d+(?:\.\d+)?)\s*(\w+)\s+([\ddhms\s]+|Infinite)/;
|
||
const match = logLine.match(downloadLinePattern);
|
||
|
||
if (match) {
|
||
// Extract download information
|
||
const downloadInfo = {
|
||
strikes: match[1],
|
||
status: match[2],
|
||
name: match[3],
|
||
size: match[4] + ' ' + match[5],
|
||
eta: match[6],
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
// Update or add to our list of downloads
|
||
this.updateDownloadsList(downloadInfo);
|
||
this.renderTableView();
|
||
}
|
||
|
||
// If we're viewing the Swaparr tab, always ensure content is rendered
|
||
if (app.currentLogApp === 'swaparr') {
|
||
this.ensureContentRendered();
|
||
}
|
||
},
|
||
|
||
extractSessionStats: function(logLine) {
|
||
// Extract session statistics from log line
|
||
// Format: "Session stats - Strikes: X, Removed: Y, Ignored: Z, API calls: W"
|
||
const strikes = logLine.match(/Strikes: (\d+)/);
|
||
const removed = logLine.match(/Removed: (\d+)/);
|
||
const ignored = logLine.match(/Ignored: (\d+)/);
|
||
const apiCalls = logLine.match(/API calls: (\d+)/);
|
||
|
||
if (strikes) this.logData.statistics.session.strikes_added = parseInt(strikes[1]);
|
||
if (removed) this.logData.statistics.session.downloads_removed = parseInt(removed[1]);
|
||
if (ignored) this.logData.statistics.session.items_ignored = parseInt(ignored[1]);
|
||
if (apiCalls) this.logData.statistics.session.api_calls_made = parseInt(apiCalls[1]);
|
||
|
||
this.logData.statistics.session.last_update = new Date().toISOString();
|
||
},
|
||
|
||
// Process enhanced strike-related logs from system logs
|
||
processStrikeLog: function(logLine) {
|
||
// Try to extract download name and strike info
|
||
let downloadName = '';
|
||
let strikes = '1/3'; // Default value
|
||
let status = 'Striked';
|
||
|
||
// Extract download name and update statistics
|
||
if (logLine.includes('Added strike')) {
|
||
const match = logLine.match(/Added strike \((\d+)\/(\d+)\) to (.+?) - Reason:/);
|
||
if (match) {
|
||
strikes = `${match[1]}/${match[2]}`;
|
||
downloadName = match[3];
|
||
status = 'Striked';
|
||
this.logData.statistics.session.strikes_added++;
|
||
}
|
||
} else if (logLine.includes('Max strikes reached')) {
|
||
const match = logLine.match(/Max strikes reached for (.+?), removing download/);
|
||
if (match) {
|
||
downloadName = match[1];
|
||
status = 'Removing';
|
||
}
|
||
} else if (logLine.includes('Successfully removed')) {
|
||
const match = logLine.match(/Successfully removed (.+?) after (\d+) strikes/);
|
||
if (match) {
|
||
downloadName = match[1];
|
||
status = 'Removed';
|
||
strikes = `${match[2]}/3`;
|
||
this.logData.statistics.session.downloads_removed++;
|
||
}
|
||
} else if (logLine.includes('Would have removed')) {
|
||
const match = logLine.match(/Would have removed (.+?) after (\d+) strikes/);
|
||
if (match) {
|
||
downloadName = match[1];
|
||
status = 'Pending Removal (Dry Run)';
|
||
strikes = `${match[2]}/3`;
|
||
}
|
||
} else if (logLine.includes('Re-removed previously removed')) {
|
||
const match = logLine.match(/Re-removed previously removed download: (.+)/);
|
||
if (match) {
|
||
downloadName = match[1];
|
||
status = 'Re-removed';
|
||
this.logData.statistics.session.downloads_removed++;
|
||
}
|
||
}
|
||
|
||
if (downloadName) {
|
||
// Create a download info object with partial information
|
||
const downloadInfo = {
|
||
strikes: strikes,
|
||
status: status,
|
||
name: downloadName,
|
||
size: 'Unknown',
|
||
eta: 'Unknown',
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
// Update downloads list
|
||
this.updateDownloadsList(downloadInfo);
|
||
this.renderTableView();
|
||
this.renderStatisticsPanel(); // Update statistics display
|
||
}
|
||
},
|
||
|
||
extractConfigInfo: function(logLine) {
|
||
// Extract the config data from the log line
|
||
const platformMatch = logLine.match(/Platform:\s+(\w+)/);
|
||
const maxStrikesMatch = logLine.match(/Max strikes:\s+(\d+)/);
|
||
const scanIntervalMatch = logLine.match(/Scan interval:\s+(\d+\w+)/);
|
||
const maxDownloadTimeMatch = logLine.match(/Max download time:\s+(\d+\w+)/);
|
||
const ignoreSizeMatch = logLine.match(/Ignore above size:\s+(\d+\s*\w+)/);
|
||
|
||
if (platformMatch) this.logData.config.platform = platformMatch[1];
|
||
if (maxStrikesMatch) this.logData.config.maxStrikes = maxStrikesMatch[1];
|
||
if (scanIntervalMatch) this.logData.config.scanInterval = scanIntervalMatch[1];
|
||
if (maxDownloadTimeMatch) this.logData.config.maxDownloadTime = maxDownloadTimeMatch[1];
|
||
if (ignoreSizeMatch) this.logData.config.ignoreAboveSize = ignoreSizeMatch[1];
|
||
},
|
||
|
||
updateDownloadsList: function(downloadInfo) {
|
||
// Find if this download already exists in our list
|
||
const existingIndex = this.logData.downloads.findIndex(item =>
|
||
item.name.trim() === downloadInfo.name.trim()
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
// Update existing entry but preserve timestamp if newer
|
||
const existing = this.logData.downloads[existingIndex];
|
||
this.logData.downloads[existingIndex] = {
|
||
...downloadInfo,
|
||
first_seen: existing.first_seen || existing.timestamp || downloadInfo.timestamp
|
||
};
|
||
} else {
|
||
// Add new entry
|
||
downloadInfo.first_seen = downloadInfo.timestamp;
|
||
this.logData.downloads.push(downloadInfo);
|
||
}
|
||
|
||
// Keep only the last 100 downloads to prevent memory issues
|
||
if (this.logData.downloads.length > 100) {
|
||
this.logData.downloads = this.logData.downloads.slice(-100);
|
||
}
|
||
},
|
||
|
||
renderConfigPanel: function() {
|
||
// Find the logs container
|
||
const logsContainer = document.getElementById('logsContainer');
|
||
if (!logsContainer) return;
|
||
|
||
// If the user has selected swaparr logs, show the config panel at the top
|
||
if (app.currentLogApp === 'swaparr') {
|
||
// Check if config panel already exists
|
||
let configPanel = document.getElementById('swaparr-config-panel');
|
||
if (!configPanel) {
|
||
// Create the panel
|
||
configPanel = document.createElement('div');
|
||
configPanel.id = 'swaparr-config-panel';
|
||
configPanel.classList.add('swaparr-panel');
|
||
logsContainer.appendChild(configPanel);
|
||
}
|
||
|
||
const dryRunBadge = this.logData.config.dryRun ?
|
||
'<span class="swaparr-badge swaparr-badge-warning">DRY RUN</span>' : '';
|
||
|
||
// Update the panel content with enhanced information
|
||
configPanel.innerHTML = `
|
||
<div class="swaparr-config">
|
||
<h3>
|
||
<i class="fas fa-exchange-alt"></i>
|
||
Swaparr${this.logData.config.platform ? ' — ' + this.logData.config.platform : ''}
|
||
${dryRunBadge}
|
||
</h3>
|
||
<div class="swaparr-config-content">
|
||
<div class="config-item">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<span>Max strikes: <strong>${this.logData.config.maxStrikes}</strong></span>
|
||
</div>
|
||
<div class="config-item">
|
||
<i class="fas fa-clock"></i>
|
||
<span>Max download time: <strong>${this.logData.config.maxDownloadTime}</strong></span>
|
||
</div>
|
||
<div class="config-item">
|
||
<i class="fas fa-weight-hanging"></i>
|
||
<span>Ignore above: <strong>${this.logData.config.ignoreAboveSize}</strong></span>
|
||
</div>
|
||
<div class="config-item">
|
||
<i class="fas fa-trash-alt"></i>
|
||
<span>Remove from client: <strong>${this.logData.config.removeFromClient ? 'Yes' : 'No'}</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.hasRenderedAnyContent = true;
|
||
}
|
||
},
|
||
|
||
renderStatisticsPanel: function() {
|
||
// Find the logs container
|
||
const logsContainer = document.getElementById('logsContainer');
|
||
if (!logsContainer || app.currentLogApp !== 'swaparr') return;
|
||
|
||
// Check if statistics panel already exists
|
||
let statsPanel = document.getElementById('swaparr-stats-panel');
|
||
if (!statsPanel) {
|
||
// Create the panel
|
||
statsPanel = document.createElement('div');
|
||
statsPanel.id = 'swaparr-stats-panel';
|
||
statsPanel.classList.add('swaparr-panel');
|
||
logsContainer.appendChild(statsPanel);
|
||
}
|
||
|
||
const stats = this.logData.statistics.session;
|
||
const lastUpdate = stats.last_update ?
|
||
new Date(stats.last_update).toLocaleTimeString() : 'Never';
|
||
|
||
// Generate app-specific statistics
|
||
let appStatsHtml = '';
|
||
for (const [appName, appStats] of Object.entries(this.logData.statistics.apps)) {
|
||
if (appStats.error) continue;
|
||
|
||
appStatsHtml += `
|
||
<div class="app-stat">
|
||
<strong>${appName.toUpperCase()}</strong>:
|
||
${appStats.currently_striked || 0} striked,
|
||
${appStats.total_removed || 0} removed
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Update the panel content
|
||
statsPanel.innerHTML = `
|
||
<div class="swaparr-statistics">
|
||
<h4><i class="fas fa-chart-line"></i> Session Statistics</h4>
|
||
<div class="stats-grid">
|
||
<div class="stat-item">
|
||
<i class="fas fa-tasks"></i>
|
||
<span class="stat-value">${stats.total_processed || 0}</span>
|
||
<span class="stat-label">Processed</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<span class="stat-value">${stats.strikes_added || 0}</span>
|
||
<span class="stat-label">Strikes Added</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-trash-alt"></i>
|
||
<span class="stat-value">${stats.downloads_removed || 0}</span>
|
||
<span class="stat-label">Removed</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-eye-slash"></i>
|
||
<span class="stat-value">${stats.items_ignored || 0}</span>
|
||
<span class="stat-label">Ignored</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-network-wired"></i>
|
||
<span class="stat-value">${stats.api_calls_made || 0}</span>
|
||
<span class="stat-label">API Calls</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-exclamation-circle"></i>
|
||
<span class="stat-value">${stats.errors_encountered || 0}</span>
|
||
<span class="stat-label">Errors</span>
|
||
</div>
|
||
</div>
|
||
<div class="stats-apps">
|
||
${appStatsHtml}
|
||
</div>
|
||
<div class="stats-footer">
|
||
<small>Last update: ${lastUpdate}</small>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.hasRenderedAnyContent = true;
|
||
},
|
||
|
||
renderTableView: function() {
|
||
// Find the logs container
|
||
const logsContainer = document.getElementById('logsContainer');
|
||
if (!logsContainer || app.currentLogApp !== 'swaparr') return;
|
||
|
||
// Check if table already exists
|
||
let tableView = document.getElementById('swaparr-table-view');
|
||
if (!tableView) {
|
||
// Create the table
|
||
tableView = document.createElement('div');
|
||
tableView.id = 'swaparr-table-view';
|
||
tableView.classList.add('swaparr-table');
|
||
logsContainer.appendChild(tableView);
|
||
}
|
||
|
||
// Only render table if we have downloads to show
|
||
if (this.logData.downloads.length > 0) {
|
||
// Generate table HTML with enhanced styling
|
||
let tableHTML = `
|
||
<div class="swaparr-table-header">
|
||
<h4><i class="fas fa-download"></i> Download Queue Status (${this.logData.downloads.length} items)</h4>
|
||
</div>
|
||
<table class="swaparr-downloads-table">
|
||
<thead>
|
||
<tr>
|
||
<th><i class="fas fa-exclamation-triangle"></i> Strikes</th>
|
||
<th><i class="fas fa-info-circle"></i> Status</th>
|
||
<th><i class="fas fa-file"></i> Name</th>
|
||
<th><i class="fas fa-weight-hanging"></i> Size</th>
|
||
<th><i class="fas fa-clock"></i> ETA</th>
|
||
<th><i class="fas fa-calendar-alt"></i> First Seen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
// Sort downloads by timestamp (newest first)
|
||
const sortedDownloads = [...this.logData.downloads].sort((a, b) =>
|
||
new Date(b.timestamp || 0) - new Date(a.timestamp || 0)
|
||
);
|
||
|
||
// Add each download as a row
|
||
sortedDownloads.forEach(download => {
|
||
// Apply status-specific CSS class
|
||
let statusClass = download.status.toLowerCase().replace(/\s+/g, '-');
|
||
|
||
// Normalize some status values
|
||
if (statusClass.includes('pending')) statusClass = 'pending';
|
||
if (statusClass.includes('removed')) statusClass = 'removed';
|
||
if (statusClass.includes('striked')) statusClass = 'striked';
|
||
if (statusClass.includes('normal')) statusClass = 'normal';
|
||
if (statusClass.includes('ignored')) statusClass = 'ignored';
|
||
if (statusClass.includes('dry-run')) statusClass = 'dry-run';
|
||
|
||
const firstSeen = download.first_seen ?
|
||
new Date(download.first_seen).toLocaleString() : 'Unknown';
|
||
|
||
tableHTML += `
|
||
<tr class="swaparr-status-${statusClass}">
|
||
<td><span class="strikes-badge">${download.strikes}</span></td>
|
||
<td><span class="status-badge status-${statusClass}">${download.status}</span></td>
|
||
<td title="${download.name}">${download.name}</td>
|
||
<td>${download.size}</td>
|
||
<td>${download.eta}</td>
|
||
<td><small>${firstSeen}</small></td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
tableHTML += `
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
tableView.innerHTML = tableHTML;
|
||
this.hasRenderedAnyContent = true;
|
||
} else {
|
||
// Show empty state
|
||
tableView.innerHTML = `
|
||
<div class="swaparr-empty-state">
|
||
<i class="fas fa-download"></i>
|
||
<h4>No Downloads Tracked</h4>
|
||
<p>Swaparr is monitoring download queues but hasn't found any stalled downloads yet.</p>
|
||
</div>
|
||
`;
|
||
this.hasRenderedAnyContent = true;
|
||
}
|
||
},
|
||
|
||
// Render raw logs if we don't have structured content
|
||
renderRawLogs: function() {
|
||
// Only show raw logs if we have no other content
|
||
if (this.hasRenderedAnyContent) return;
|
||
|
||
const logsContainer = document.getElementById('logsContainer');
|
||
if (!logsContainer || app.currentLogApp !== 'swaparr') return;
|
||
|
||
// Start with a message
|
||
const noDataMessage = document.createElement('div');
|
||
noDataMessage.classList.add('swaparr-panel');
|
||
noDataMessage.innerHTML = `
|
||
<div class="swaparr-config">
|
||
<h3><i class="fas fa-exchange-alt"></i> Swaparr Logs</h3>
|
||
<p>Waiting for structured Swaparr data. Showing raw logs below:</p>
|
||
</div>
|
||
`;
|
||
logsContainer.appendChild(noDataMessage);
|
||
|
||
// Add raw logs
|
||
for (const logLine of this.logData.rawLogs.slice(-50)) { // Show only last 50 lines
|
||
const logEntry = document.createElement('div');
|
||
logEntry.className = 'log-entry';
|
||
logEntry.innerHTML = `<span class="log-message">${logLine}</span>`;
|
||
|
||
// Basic level detection
|
||
if (logLine.includes('ERROR')) logEntry.classList.add('log-error');
|
||
else if (logLine.includes('WARN') || logLine.includes('WARNING')) logEntry.classList.add('log-warning');
|
||
else if (logLine.includes('DEBUG')) logEntry.classList.add('log-debug');
|
||
else logEntry.classList.add('log-info');
|
||
|
||
logsContainer.appendChild(logEntry);
|
||
}
|
||
|
||
this.hasRenderedAnyContent = true;
|
||
},
|
||
|
||
// Make sure we display something in the Swaparr tab
|
||
ensureContentRendered: function() {
|
||
console.log('[Swaparr Module] Ensuring content is rendered, has content:', this.hasRenderedAnyContent);
|
||
|
||
// Reset rendered flag
|
||
this.hasRenderedAnyContent = false;
|
||
|
||
// Check if we're viewing Swaparr tab
|
||
if (app.currentLogApp !== 'swaparr') return;
|
||
|
||
// Clear existing content
|
||
const logsContainer = document.getElementById('logsContainer');
|
||
if (logsContainer) {
|
||
// Remove only Swaparr-specific content
|
||
const swaparrElements = logsContainer.querySelectorAll('[id^="swaparr-"], .swaparr-panel, .swaparr-table, .swaparr-empty-state');
|
||
swaparrElements.forEach(el => el.remove());
|
||
}
|
||
|
||
// First try to render structured content
|
||
this.renderConfigPanel();
|
||
this.renderStatisticsPanel();
|
||
this.renderTableView();
|
||
|
||
// If no structured content, show raw logs
|
||
if (!this.hasRenderedAnyContent) {
|
||
this.renderRawLogs();
|
||
}
|
||
},
|
||
|
||
// Clear the data when switching log views
|
||
clearData: function() {
|
||
this.logData.downloads = [];
|
||
// Keep raw logs and statistics for persistence
|
||
this.hasRenderedAnyContent = false;
|
||
}
|
||
};
|
||
|
||
// Initialize the module
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
swaparrModule.init();
|
||
|
||
if (app) {
|
||
app.swaparrModule = swaparrModule;
|
||
|
||
// Setup a handler for when log tabs are changed
|
||
document.querySelectorAll('.log-tab').forEach(tab => {
|
||
tab.addEventListener('click', (e) => {
|
||
// If switching to swaparr tab, make sure we render the view
|
||
if (e.target.getAttribute('data-app') === 'swaparr') {
|
||
console.log('[Swaparr Module] Swaparr tab clicked via delegation');
|
||
// Small delay to allow logs to load
|
||
setTimeout(() => {
|
||
swaparrModule.ensureContentRendered();
|
||
}, 200);
|
||
}
|
||
// If switching away from swaparr tab, clear the visual data
|
||
else if (app.currentLogApp === 'swaparr') {
|
||
swaparrModule.clearData();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
})(window.huntarrUI); // Pass the global UI object
|
||
|
||
/* === modules/ui/stats.js === */
|
||
/**
|
||
* Stats & Dashboard Module
|
||
* Handles media stats, app connections, dashboard display,
|
||
* grid/list view, live polling, and drag-and-drop reordering.
|
||
*/
|
||
|
||
window.HuntarrStats = {
|
||
isLoadingStats: false,
|
||
_pollInterval: null,
|
||
_currentViewMode: 'list', // 'grid' or 'list'
|
||
_lastRenderedMode: null, // Track which mode we last rendered
|
||
|
||
// App metadata: order, display names, icons, accent colors
|
||
APP_META: {
|
||
tv_hunt: { label: 'TV Hunt', icon: './static/logo/256.png', accent: '#a855f7' },
|
||
movie_hunt: { label: 'Movie Hunt', icon: './static/logo/256.png', accent: '#f59e0b' },
|
||
sonarr: { label: 'Sonarr', icon: './static/images/app-icons/sonarr.png', accent: '#6366f1' },
|
||
radarr: { label: 'Radarr', icon: './static/images/app-icons/radarr.png', accent: '#f59e0b' },
|
||
lidarr: { label: 'Lidarr', icon: './static/images/app-icons/lidarr.png', accent: '#22c55e' },
|
||
readarr: { label: 'Readarr', icon: './static/images/app-icons/readarr.png', accent: '#a855f7' },
|
||
whisparr: { label: 'Whisparr V2', icon: './static/images/app-icons/whisparr.png', accent: '#ec4899' },
|
||
eros: { label: 'Whisparr V3', icon: './static/images/app-icons/whisparr.png', accent: '#ec4899' }
|
||
},
|
||
DEFAULT_APP_ORDER: ['tv_hunt', 'movie_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'],
|
||
|
||
// ─── Polling ──────────────────────────────────────────────────────
|
||
startPolling: function() {
|
||
this.stopPolling();
|
||
var self = this;
|
||
this._pollInterval = setInterval(function() {
|
||
self.loadMediaStats(true);
|
||
}, 15000);
|
||
},
|
||
|
||
stopPolling: function() {
|
||
if (this._pollInterval) {
|
||
clearInterval(this._pollInterval);
|
||
this._pollInterval = null;
|
||
}
|
||
this._stopNzbHomePoll();
|
||
},
|
||
|
||
// ─── Layout Persistence ───────────────────────────────────────────
|
||
_getLayout: function() {
|
||
return HuntarrUtils.getUIPreference('dashboard-layout', null);
|
||
},
|
||
|
||
_saveLayout: function(layout) {
|
||
HuntarrUtils.setUIPreference('dashboard-layout', layout);
|
||
},
|
||
|
||
_getGroupOrder: function() {
|
||
var layout = this._getLayout();
|
||
if (layout && Array.isArray(layout.groups) && layout.groups.length > 0) {
|
||
var order = layout.groups.slice();
|
||
this.DEFAULT_APP_ORDER.forEach(function(app) {
|
||
if (order.indexOf(app) === -1) order.push(app);
|
||
});
|
||
return order;
|
||
}
|
||
return this.DEFAULT_APP_ORDER.slice();
|
||
},
|
||
|
||
_getCardOrder: function() {
|
||
var layout = this._getLayout();
|
||
if (layout && Array.isArray(layout.cards) && layout.cards.length > 0) {
|
||
return layout.cards;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Collect card order for grid mode (flat list of {app, instance} pairs)
|
||
_collectGridOrder: function() {
|
||
var grid = document.getElementById('app-stats-grid');
|
||
if (!grid) return;
|
||
var cards = grid.querySelectorAll('.app-stats-card[data-app][data-instance-name]');
|
||
var cardOrder = [];
|
||
cards.forEach(function(c) {
|
||
cardOrder.push({
|
||
app: c.getAttribute('data-app'),
|
||
instance: c.getAttribute('data-instance-name')
|
||
});
|
||
});
|
||
// Also build group order from the card order (for list mode)
|
||
var seen = {};
|
||
var groups = [];
|
||
cardOrder.forEach(function(c) {
|
||
if (!seen[c.app]) {
|
||
seen[c.app] = true;
|
||
groups.push(c.app);
|
||
}
|
||
});
|
||
this._saveLayout({ groups: groups, cards: cardOrder });
|
||
},
|
||
|
||
// Collect group order for list mode
|
||
_collectListOrder: function() {
|
||
var grid = document.getElementById('app-stats-grid');
|
||
if (!grid) return;
|
||
var groupEls = grid.querySelectorAll('.app-group');
|
||
var groups = [];
|
||
groupEls.forEach(function(g) {
|
||
var app = g.getAttribute('data-app');
|
||
if (app) groups.push(app);
|
||
});
|
||
var layout = this._getLayout() || {};
|
||
layout.groups = groups;
|
||
this._saveLayout(layout);
|
||
},
|
||
|
||
// ─── View Mode ────────────────────────────────────────────────────
|
||
_getViewMode: function() {
|
||
var mode = HuntarrUtils.getUIPreference('dashboard-view-mode', 'list');
|
||
if (mode === 'list' || mode === 'grid') return mode;
|
||
return 'list';
|
||
},
|
||
|
||
_setViewMode: function(mode) {
|
||
this._currentViewMode = mode;
|
||
HuntarrUtils.setUIPreference('dashboard-view-mode', mode);
|
||
},
|
||
|
||
initViewToggle: function() {
|
||
var self = this;
|
||
var savedMode = this._getViewMode();
|
||
var needsRerender = (this._lastRenderedMode && savedMode !== this._lastRenderedMode);
|
||
this._currentViewMode = savedMode;
|
||
|
||
var toggleGroup = document.getElementById('dashboard-view-toggle');
|
||
if (!toggleGroup) return;
|
||
|
||
// Remove old listeners by cloning
|
||
var newToggle = toggleGroup.cloneNode(true);
|
||
toggleGroup.parentNode.replaceChild(newToggle, toggleGroup);
|
||
|
||
var btns = newToggle.querySelectorAll('.view-toggle-btn');
|
||
btns.forEach(function(btn) {
|
||
btn.classList.toggle('active', btn.getAttribute('data-view') === self._currentViewMode);
|
||
btn.addEventListener('click', function() {
|
||
var mode = this.getAttribute('data-view');
|
||
if (mode === self._currentViewMode) return;
|
||
btns.forEach(function(b) { b.classList.remove('active'); });
|
||
this.classList.add('active');
|
||
self._setViewMode(mode);
|
||
self._clearDynamicContent();
|
||
if (window.mediaStats) {
|
||
self.updateStatsDisplay(window.mediaStats);
|
||
}
|
||
});
|
||
});
|
||
|
||
// If the saved view mode differs from what was rendered, re-render now
|
||
if (needsRerender && window.mediaStats) {
|
||
this._clearDynamicContent();
|
||
this.updateStatsDisplay(window.mediaStats);
|
||
}
|
||
},
|
||
|
||
// Clear all dynamically generated content + sortable instances
|
||
_clearDynamicContent: function() {
|
||
// Destroy sortable instances
|
||
if (this._sortableGrid) {
|
||
this._sortableGrid.destroy();
|
||
this._sortableGrid = null;
|
||
}
|
||
var grid = document.getElementById('app-stats-grid');
|
||
if (!grid) return;
|
||
// Remove all dynamic elements (app-group containers and direct app-stats-cards we created)
|
||
var dynamicEls = grid.querySelectorAll('.app-group, .app-stats-card.dynamic-card');
|
||
dynamicEls.forEach(function(el) { el.remove(); });
|
||
this._lastRenderedMode = null;
|
||
},
|
||
|
||
// ─── Stats Loading ────────────────────────────────────────────────
|
||
loadMediaStats: function(skipCache) {
|
||
if (this.isLoadingStats) return;
|
||
this.isLoadingStats = true;
|
||
|
||
var self = this;
|
||
var ui = window.huntarrUI || {};
|
||
|
||
if (!skipCache) {
|
||
// Skip cache if settings haven't loaded yet — prevents flash of stale data
|
||
// for disabled features. The settings .then() callback will call us again with skipCache=true.
|
||
if (!ui._settingsLoaded) skipCache = true;
|
||
|
||
// Skip cache if all hunt categories are disabled — prevents flash of stale data
|
||
var allDisabled = (ui._enableMediaHunt === false && ui._enableThirdPartyApps === false);
|
||
if (allDisabled) skipCache = true;
|
||
|
||
if (!skipCache) {
|
||
var cachedStats = localStorage.getItem('huntarr-stats-cache');
|
||
if (cachedStats) {
|
||
try {
|
||
var parsedStats = JSON.parse(cachedStats);
|
||
var cacheAge = Date.now() - (parsedStats.timestamp || 0);
|
||
// Use cache if less than 1 hour old for immediate UI
|
||
if (cacheAge < 3600000) {
|
||
this.updateStatsDisplay(parsedStats.stats, true);
|
||
// Show grid immediately from cache so it's not blank while checking connections
|
||
this.updateEmptyStateVisibility(true);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
var statsContainer = document.querySelector('.media-stats-container');
|
||
if (statsContainer && !skipCache) {
|
||
statsContainer.classList.add('stats-loading');
|
||
}
|
||
|
||
HuntarrUtils.fetchWithTimeout('./api/stats')
|
||
.then(function(response) {
|
||
if (!response.ok) throw new Error('Network response was not ok');
|
||
return response.json();
|
||
})
|
||
.then(function(data) {
|
||
if (data.success && data.stats) {
|
||
window.mediaStats = data.stats;
|
||
localStorage.setItem('huntarr-stats-cache', JSON.stringify({
|
||
stats: data.stats,
|
||
timestamp: Date.now()
|
||
}));
|
||
self.updateStatsDisplay(data.stats);
|
||
if (statsContainer) statsContainer.classList.remove('stats-loading');
|
||
}
|
||
// Always re-evaluate empty state after fresh data
|
||
self.updateEmptyStateVisibility();
|
||
})
|
||
.catch(function(error) {
|
||
console.error('Error fetching statistics:', error);
|
||
if (statsContainer) statsContainer.classList.remove('stats-loading');
|
||
})
|
||
.finally(function() {
|
||
self.isLoadingStats = false;
|
||
});
|
||
|
||
// Also fetch NZB Hunt home stats (separate from main stats pipeline)
|
||
self._fetchNzbHuntHomeStats();
|
||
self._checkNzbHuntWarning();
|
||
self._initNzbHomePauseBtn();
|
||
},
|
||
|
||
// ─── Main Display Update ──────────────────────────────────────────
|
||
updateStatsDisplay: function(stats, isFromCache) {
|
||
// If mode changed, clear and rebuild
|
||
if (this._lastRenderedMode && this._lastRenderedMode !== this._currentViewMode) {
|
||
this._clearDynamicContent();
|
||
}
|
||
if (this._currentViewMode === 'list') {
|
||
this._renderListView(stats, isFromCache);
|
||
} else {
|
||
this._renderGridView(stats, isFromCache);
|
||
}
|
||
this._lastRenderedMode = this._currentViewMode;
|
||
},
|
||
|
||
// ─── Grid View (Flat Cards with Drag Handles) ─────────────────────
|
||
_renderGridView: function(stats, isFromCache) {
|
||
var grid = document.getElementById('app-stats-grid');
|
||
if (!grid) {
|
||
grid = document.querySelector('.app-stats-grid');
|
||
if (grid) grid.id = 'app-stats-grid';
|
||
else return;
|
||
}
|
||
|
||
// Switch CSS class
|
||
grid.classList.remove('app-stats-list');
|
||
grid.classList.add('app-stats-grid');
|
||
|
||
var self = this;
|
||
var groupOrder = this._getGroupOrder();
|
||
var savedCardOrder = this._getCardOrder();
|
||
|
||
// Build a flat list of all cards to render: [{app, meta, inst}, ...]
|
||
var allCards = [];
|
||
var ui = window.huntarrUI || {};
|
||
var mediaHuntApps = { movie_hunt: true, tv_hunt: true };
|
||
var thirdPartyApps = { sonarr: true, radarr: true, lidarr: true, readarr: true, whisparr: true, eros: true };
|
||
groupOrder.forEach(function(app) {
|
||
if (!stats[app]) return;
|
||
if (mediaHuntApps[app] && ui._enableMediaHunt === false) return;
|
||
if (thirdPartyApps[app] && ui._enableThirdPartyApps === false) return;
|
||
var hasInstances = stats[app].instances && stats[app].instances.length > 0;
|
||
var isConfigured = ui.configuredApps && ui.configuredApps[app];
|
||
if (!hasInstances && !stats[app].hunted && !stats[app].upgraded && !isConfigured) return;
|
||
|
||
var meta = self.APP_META[app] || { label: app, icon: '', accent: '#94a3b8' };
|
||
var instances = hasInstances ? stats[app].instances : [];
|
||
|
||
if (instances.length === 0) {
|
||
allCards.push({
|
||
app: app,
|
||
meta: meta,
|
||
inst: {
|
||
hunted: stats[app].hunted || 0,
|
||
upgraded: stats[app].upgraded || 0,
|
||
found: stats[app].found || 0,
|
||
found_upgrade: stats[app].found_upgrade || 0,
|
||
api_hits: 0, api_limit: 20,
|
||
instance_name: meta.label,
|
||
api_url: ''
|
||
}
|
||
});
|
||
} else {
|
||
instances.forEach(function(inst) {
|
||
allCards.push({ app: app, meta: meta, inst: inst });
|
||
});
|
||
}
|
||
});
|
||
|
||
// Apply saved card order if available
|
||
if (savedCardOrder && savedCardOrder.length > 0) {
|
||
allCards.sort(function(a, b) {
|
||
var keyA = a.app + '|' + (a.inst.instance_name || '');
|
||
var keyB = b.app + '|' + (b.inst.instance_name || '');
|
||
var idxA = -1, idxB = -1;
|
||
for (var i = 0; i < savedCardOrder.length; i++) {
|
||
var sk = savedCardOrder[i].app + '|' + (savedCardOrder[i].instance || '');
|
||
if (sk === keyA) idxA = i;
|
||
if (sk === keyB) idxB = i;
|
||
}
|
||
if (idxA === -1) idxA = 9999;
|
||
if (idxB === -1) idxB = 9999;
|
||
return idxA - idxB;
|
||
});
|
||
}
|
||
|
||
// Build/update cards in DOM
|
||
var existingCards = grid.querySelectorAll('.app-stats-card.dynamic-card');
|
||
var existingMap = {};
|
||
existingCards.forEach(function(c) {
|
||
var key = c.getAttribute('data-app') + '|' + c.getAttribute('data-instance-name');
|
||
existingMap[key] = c;
|
||
});
|
||
|
||
allCards.forEach(function(entry, idx) {
|
||
var key = entry.app + '|' + (entry.inst.instance_name || '');
|
||
var card = existingMap[key];
|
||
if (!card) {
|
||
card = self._createCard(entry.app, entry.meta);
|
||
card.classList.add('dynamic-card');
|
||
card.setAttribute('data-app', entry.app);
|
||
grid.appendChild(card);
|
||
}
|
||
self._updateCard(card, entry.app, entry.meta, entry.inst, isFromCache, entry.meta.label);
|
||
// Ensure it's in the grid at the right position
|
||
grid.appendChild(card);
|
||
delete existingMap[key];
|
||
});
|
||
|
||
// Remove cards no longer in data
|
||
Object.keys(existingMap).forEach(function(key) {
|
||
existingMap[key].remove();
|
||
});
|
||
|
||
// Hide old static cards from template
|
||
var oldCards = grid.querySelectorAll(':scope > .app-stats-card:not(.dynamic-card), :scope > .app-stats-card-wrapper, :scope > .app-group');
|
||
oldCards.forEach(function(c) { c.style.display = 'none'; });
|
||
|
||
// Initialize SortableJS for flat grid
|
||
this._initGridSortable(grid);
|
||
|
||
// Refresh cycle timers — timer elements are already baked into cards,
|
||
// but CycleCountdown needs to know about them and populate data
|
||
this._refreshCycleTimers();
|
||
|
||
if (allCards.length > 0) {
|
||
this.updateEmptyStateVisibility(true);
|
||
}
|
||
setTimeout(function() {
|
||
if (typeof window.loadHourlyCapData === 'function') {
|
||
window.loadHourlyCapData();
|
||
}
|
||
}, 200);
|
||
},
|
||
|
||
// ─── Create a Card Element (with drag handle + baked-in timer) ────
|
||
_createCard: function(app, meta) {
|
||
var card = document.createElement('div');
|
||
card.className = 'app-stats-card ' + app;
|
||
var cssClass = app.replace(/-/g, '');
|
||
card.innerHTML =
|
||
'<div class="card-drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></div>' +
|
||
'<div class="hourly-cap-container">' +
|
||
'<div class="hourly-cap-status">' +
|
||
'<span class="hourly-cap-icon"></span>' +
|
||
'<span class="hourly-cap-text">API: <span>0</span> / <span>--</span></span>' +
|
||
'</div>' +
|
||
'<div class="api-progress-container">' +
|
||
'<div class="api-progress-bar"><div class="api-progress-fill" style="width: 0%;"></div></div>' +
|
||
'<div class="api-progress-text">API: <span>0</span> / <span>--</span></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="app-content">' +
|
||
'<div class="app-icon-wrapper"><img src="' + meta.icon + '" alt="" class="app-logo"></div>' +
|
||
'<h4>' + meta.label + '</h4>' +
|
||
'</div>' +
|
||
'<div class="stats-numbers">' +
|
||
'<div class="stat-box">' +
|
||
(app === 'movie_hunt' || app === 'tv_hunt'
|
||
? '<span class="stat-number-found-wrap"><span class="stat-number stat-found">0</span> / <span class="stat-number">0</span></span>'
|
||
: '<span class="stat-number">0</span>') +
|
||
'<span class="stat-label">' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'Found / Searched' : 'Searches Triggered') + '</span>' +
|
||
'</div>' +
|
||
'<div class="stat-box">' +
|
||
(app === 'movie_hunt' || app === 'tv_hunt'
|
||
? '<span class="stat-number-found-wrap"><span class="stat-number stat-found">0</span> / <span class="stat-number">0</span></span>'
|
||
: '<span class="stat-number">0</span>') +
|
||
'<span class="stat-label">' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'Found / Upgrades' : 'Upgrades Triggered') + '</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="reset-button-container">' +
|
||
'<div class="reset-and-timer-container">' +
|
||
'<button class="cycle-reset-button" data-app="' + app + '"><i class="fas fa-sync-alt"></i> Reset</button>' +
|
||
'<div class="cycle-timer inline-timer ' + cssClass + '" data-app-type="' + app + '">' +
|
||
'<i class="fas fa-clock ' + cssClass + '-icon"></i> <span class="timer-value">Loading...</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
return card;
|
||
},
|
||
|
||
// ─── Update a Card Element ────────────────────────────────────────
|
||
_updateCard: function(card, app, meta, inst, isFromCache, appLabel) {
|
||
var hunted = Math.max(0, parseInt(inst.hunted) || 0);
|
||
var upgraded = Math.max(0, parseInt(inst.upgraded) || 0);
|
||
var name = inst.instance_name || 'Default';
|
||
var apiHits = Math.max(0, parseInt(inst.api_hits) || 0);
|
||
var apiLimit = Math.max(1, parseInt(inst.api_limit) || 20);
|
||
var apiUrl = (inst.api_url || '').trim();
|
||
|
||
card.style.display = '';
|
||
card.setAttribute('data-instance-name', name);
|
||
card.setAttribute('data-app', app);
|
||
|
||
// Title
|
||
var h4 = card.querySelector('.app-content h4');
|
||
if (h4) {
|
||
var displayText = name !== appLabel ? appLabel + ' \u2013 ' + name : appLabel;
|
||
if (apiUrl) {
|
||
var link = h4.querySelector('.instance-name-link');
|
||
if (!link) {
|
||
h4.textContent = '';
|
||
link = document.createElement('a');
|
||
link.className = 'instance-name-link';
|
||
link.target = '_blank';
|
||
link.rel = 'noopener noreferrer';
|
||
link.title = 'Open instance in new tab';
|
||
h4.appendChild(link);
|
||
}
|
||
link.href = apiUrl;
|
||
link.textContent = displayText;
|
||
} else {
|
||
h4.textContent = displayText;
|
||
}
|
||
}
|
||
|
||
// Stat numbers — Movie Hunt uses "found / searched" layout
|
||
if (app === 'movie_hunt' || app === 'tv_hunt') {
|
||
var found = Math.max(0, parseInt(inst.found) || 0);
|
||
var foundUpgrade = Math.max(0, parseInt(inst.found_upgrade) || 0);
|
||
var statBoxes = card.querySelectorAll('.stat-box');
|
||
// First box: Found / Searched
|
||
if (statBoxes[0]) {
|
||
var nums0 = statBoxes[0].querySelectorAll('.stat-number');
|
||
if (nums0[0]) { // found
|
||
if (isFromCache) nums0[0].textContent = this.formatLargeNumber(found);
|
||
else this.animateNumber(nums0[0], this.parseFormattedNumber(nums0[0].textContent || '0'), found);
|
||
}
|
||
if (nums0[1]) { // hunted
|
||
if (isFromCache) nums0[1].textContent = this.formatLargeNumber(hunted);
|
||
else this.animateNumber(nums0[1], this.parseFormattedNumber(nums0[1].textContent || '0'), hunted);
|
||
}
|
||
}
|
||
// Second box: Found / Upgrades
|
||
if (statBoxes[1]) {
|
||
var nums1 = statBoxes[1].querySelectorAll('.stat-number');
|
||
if (nums1[0]) { // found_upgrade
|
||
if (isFromCache) nums1[0].textContent = this.formatLargeNumber(foundUpgrade);
|
||
else this.animateNumber(nums1[0], this.parseFormattedNumber(nums1[0].textContent || '0'), foundUpgrade);
|
||
}
|
||
if (nums1[1]) { // upgraded
|
||
if (isFromCache) nums1[1].textContent = this.formatLargeNumber(upgraded);
|
||
else this.animateNumber(nums1[1], this.parseFormattedNumber(nums1[1].textContent || '0'), upgraded);
|
||
}
|
||
}
|
||
} else {
|
||
var numbers = card.querySelectorAll('.stat-number');
|
||
if (numbers[0]) {
|
||
if (isFromCache) numbers[0].textContent = this.formatLargeNumber(hunted);
|
||
else this.animateNumber(numbers[0], this.parseFormattedNumber(numbers[0].textContent || '0'), hunted);
|
||
}
|
||
if (numbers[1]) {
|
||
if (isFromCache) numbers[1].textContent = this.formatLargeNumber(upgraded);
|
||
else this.animateNumber(numbers[1], this.parseFormattedNumber(numbers[1].textContent || '0'), upgraded);
|
||
}
|
||
}
|
||
|
||
// Reset button instance name
|
||
var resetBtn = card.querySelector('.cycle-reset-button[data-app]');
|
||
if (resetBtn) resetBtn.setAttribute('data-instance-name', name);
|
||
|
||
// API progress
|
||
var pct = apiLimit > 0 ? (apiHits / apiLimit) * 100 : 0;
|
||
var capSpans = card.querySelectorAll('.hourly-cap-text span');
|
||
if (capSpans.length >= 2) { capSpans[0].textContent = apiHits; capSpans[1].textContent = apiLimit; }
|
||
var statusEl = card.querySelector('.hourly-cap-status');
|
||
if (statusEl) {
|
||
statusEl.classList.remove('good', 'warning', 'danger');
|
||
if (pct >= 100) statusEl.classList.add('danger');
|
||
else if (pct >= 75) statusEl.classList.add('warning');
|
||
else statusEl.classList.add('good');
|
||
}
|
||
var progressFill = card.querySelector('.api-progress-fill');
|
||
if (progressFill) progressFill.style.width = Math.min(100, pct) + '%';
|
||
var progressSpans = card.querySelectorAll('.api-progress-text span');
|
||
if (progressSpans.length >= 2) { progressSpans[0].textContent = apiHits; progressSpans[1].textContent = apiLimit; }
|
||
|
||
// State Management reset countdown
|
||
var hoursUntil = inst.state_reset_hours_until;
|
||
var stateEnabled = inst.state_reset_enabled !== false;
|
||
var resetCountdownEl = card.querySelector('.state-reset-countdown');
|
||
var resetContainer = card.querySelector('.reset-button-container');
|
||
if (resetContainer) {
|
||
if (!resetCountdownEl) {
|
||
resetCountdownEl = document.createElement('div');
|
||
resetCountdownEl.className = 'state-reset-countdown';
|
||
resetContainer.appendChild(resetCountdownEl);
|
||
}
|
||
if (!stateEnabled) {
|
||
resetCountdownEl.innerHTML = '<i class="fas fa-hourglass-half"></i> <span class="custom-tooltip">State Management Reset</span> Disabled';
|
||
resetCountdownEl.style.display = '';
|
||
} else if (hoursUntil != null && typeof hoursUntil === 'number' && hoursUntil > 0) {
|
||
var h = Math.floor(hoursUntil);
|
||
var label = h >= 1 ? '' + h : '<1';
|
||
resetCountdownEl.innerHTML = '<i class="fas fa-hourglass-half"></i> <span class="custom-tooltip">State Management Reset</span> ' + label;
|
||
resetCountdownEl.style.display = '';
|
||
} else {
|
||
resetCountdownEl.style.display = 'none';
|
||
}
|
||
}
|
||
},
|
||
|
||
// ─── List View (Compact Table — grouped) ──────────────────────────
|
||
_renderListView: function(stats, isFromCache) {
|
||
var grid = document.getElementById('app-stats-grid');
|
||
if (!grid) {
|
||
grid = document.querySelector('.app-stats-grid');
|
||
if (grid) grid.id = 'app-stats-grid';
|
||
else return;
|
||
}
|
||
|
||
grid.classList.remove('app-stats-grid');
|
||
grid.classList.add('app-stats-list');
|
||
|
||
var self = this;
|
||
var groupOrder = this._getGroupOrder();
|
||
var visibleApps = [];
|
||
var ui = window.huntarrUI || {};
|
||
var mediaHuntApps = { movie_hunt: true, tv_hunt: true };
|
||
var thirdPartyApps = { sonarr: true, radarr: true, lidarr: true, readarr: true, whisparr: true, eros: true };
|
||
groupOrder.forEach(function(app) {
|
||
if (mediaHuntApps[app] && ui._enableMediaHunt === false) return;
|
||
if (thirdPartyApps[app] && ui._enableThirdPartyApps === false) return;
|
||
if (stats[app] && (stats[app].instances && stats[app].instances.length > 0 ||
|
||
stats[app].hunted > 0 || stats[app].upgraded > 0)) {
|
||
visibleApps.push(app);
|
||
} else if (stats[app] && ui.configuredApps && ui.configuredApps[app]) {
|
||
visibleApps.push(app);
|
||
}
|
||
});
|
||
|
||
visibleApps.forEach(function(app) {
|
||
var meta = self.APP_META[app] || { label: app, icon: '', accent: '#94a3b8' };
|
||
var group = grid.querySelector('.app-group[data-app="' + app + '"]');
|
||
|
||
if (!group) {
|
||
group = document.createElement('div');
|
||
group.className = 'app-group';
|
||
group.setAttribute('data-app', app);
|
||
grid.appendChild(group);
|
||
}
|
||
|
||
var instances = (stats[app] && stats[app].instances) || [];
|
||
if (instances.length === 0) {
|
||
instances = [{
|
||
instance_name: meta.label,
|
||
hunted: (stats[app] && stats[app].hunted) || 0,
|
||
upgraded: (stats[app] && stats[app].upgraded) || 0,
|
||
found: (stats[app] && stats[app].found) || 0,
|
||
found_upgrade: (stats[app] && stats[app].found_upgrade) || 0,
|
||
api_hits: 0, api_limit: 20, api_url: ''
|
||
}];
|
||
}
|
||
|
||
var html =
|
||
'<div class="app-group-header list-header">' +
|
||
'<i class="fas fa-grip-vertical drag-handle group-drag-handle"></i>' +
|
||
'<img src="' + meta.icon + '" class="app-group-logo" alt="">' +
|
||
'<span class="app-group-label">' + meta.label + '</span>' +
|
||
'</div>' +
|
||
'<table class="app-list-table">' +
|
||
'<colgroup>' +
|
||
'<col class="col-instance">' +
|
||
'<col class="col-searches">' +
|
||
'<col class="col-upgrades">' +
|
||
'<col class="col-api-status">' +
|
||
'<col class="col-actions">' +
|
||
'</colgroup>' +
|
||
'<thead><tr>' +
|
||
'<th>Instance</th>' +
|
||
'<th class="col-searches" data-abbr="' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'F/Srch' : 'Searches') + '">' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'Found / Searches' : 'Searches') + '</th>' +
|
||
'<th class="col-upgrades" data-abbr="' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'F/Upg' : 'Upgrades') + '">' + (app === 'movie_hunt' || app === 'tv_hunt' ? 'Found / Upgrades' : 'Upgrades') + '</th>' +
|
||
'<th>API / Status</th>' +
|
||
'<th></th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
var cssClass = app.replace(/-/g, '');
|
||
instances.forEach(function(inst) {
|
||
var hunted = Math.max(0, parseInt(inst.hunted) || 0);
|
||
var upgraded = Math.max(0, parseInt(inst.upgraded) || 0);
|
||
var found = Math.max(0, parseInt(inst.found) || 0);
|
||
var foundUpgrade = Math.max(0, parseInt(inst.found_upgrade) || 0);
|
||
var apiHits = Math.max(0, parseInt(inst.api_hits) || 0);
|
||
var apiLimit = Math.max(1, parseInt(inst.api_limit) || 20);
|
||
var pct = apiLimit > 0 ? Math.min(100, (apiHits / apiLimit) * 100) : 0;
|
||
var name = inst.instance_name || 'Default';
|
||
|
||
// Movie Hunt shows "found / searched" and "found / upgrades"
|
||
var searchesCell = (app === 'movie_hunt' || app === 'tv_hunt')
|
||
? '<span class="found-ratio"><span class="found-num">' + self.formatLargeNumber(found) + '</span> / ' + self.formatLargeNumber(hunted) + '</span>'
|
||
: self.formatLargeNumber(hunted);
|
||
var upgradesCell = (app === 'movie_hunt' || app === 'tv_hunt')
|
||
? '<span class="found-ratio"><span class="found-num">' + self.formatLargeNumber(foundUpgrade) + '</span> / ' + self.formatLargeNumber(upgraded) + '</span>'
|
||
: self.formatLargeNumber(upgraded);
|
||
|
||
html +=
|
||
'<tr data-instance-name="' + name + '">' +
|
||
'<td class="list-instance-name">' + name + '</td>' +
|
||
'<td class="list-stat ' + app + '">' + searchesCell + '</td>' +
|
||
'<td class="list-stat ' + app + '">' + upgradesCell + '</td>' +
|
||
'<td class="list-api-status">' +
|
||
'<div class="list-api-row">' +
|
||
'<div class="list-api-bar"><div class="list-api-fill ' + app + '" style="width:' + pct + '%;"></div></div>' +
|
||
'<span class="list-api-text">' + apiHits + '/' + apiLimit + '</span>' +
|
||
'</div>' +
|
||
'<div class="list-status-row">' +
|
||
'<div class="cycle-timer inline-timer ' + cssClass + '" data-app-type="' + app + '">' +
|
||
'<i class="fas fa-clock ' + cssClass + '-icon"></i> <span class="timer-value">Loading...</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</td>' +
|
||
'<td class="list-actions">' +
|
||
'<button class="cycle-reset-button" data-app="' + app + '" data-instance-name="' + name + '" title="Reset Cycle"><i class="fas fa-sync-alt"></i></button>' +
|
||
'</td>' +
|
||
'</tr>';
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
group.innerHTML = html;
|
||
group.style.display = '';
|
||
});
|
||
|
||
// Hide groups for non-visible apps
|
||
grid.querySelectorAll('.app-group').forEach(function(g) {
|
||
if (visibleApps.indexOf(g.getAttribute('data-app')) === -1) {
|
||
g.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Reorder groups
|
||
var currentGroups = Array.from(grid.querySelectorAll('.app-group'));
|
||
var sorted = currentGroups.slice().sort(function(a, b) {
|
||
var ia = groupOrder.indexOf(a.getAttribute('data-app'));
|
||
var ib = groupOrder.indexOf(b.getAttribute('data-app'));
|
||
if (ia === -1) ia = 9999;
|
||
if (ib === -1) ib = 9999;
|
||
return ia - ib;
|
||
});
|
||
sorted.forEach(function(g) { grid.appendChild(g); });
|
||
|
||
this._initListSortable(grid);
|
||
|
||
// Hide old static cards & dynamic grid cards
|
||
var oldCards = grid.querySelectorAll(':scope > .app-stats-card, :scope > .app-stats-card-wrapper');
|
||
oldCards.forEach(function(c) { c.style.display = 'none'; });
|
||
|
||
// Refresh cycle timers — timer elements are baked into each <tr>
|
||
this._refreshCycleTimers();
|
||
|
||
if (visibleApps.length > 0) {
|
||
this.updateEmptyStateVisibility(true);
|
||
}
|
||
},
|
||
|
||
// ─── Refresh Cycle Timers after view render ──────────────────────
|
||
_refreshCycleTimers: function() {
|
||
if (typeof window.CycleCountdown === 'undefined') return;
|
||
// Let CycleCountdown discover any new timer elements it doesn't know about
|
||
if (window.CycleCountdown.refreshTimerElements) {
|
||
window.CycleCountdown.refreshTimerElements();
|
||
}
|
||
// Force an immediate data fetch + display update so timers show current state
|
||
if (window.CycleCountdown.refreshAllData) {
|
||
window.CycleCountdown.refreshAllData();
|
||
}
|
||
},
|
||
|
||
// ─── SortableJS for Grid (flat cards) ─────────────────────────────
|
||
_sortableGrid: null,
|
||
|
||
_initGridSortable: function(grid) {
|
||
if (typeof Sortable === 'undefined') return;
|
||
var self = this;
|
||
|
||
if (this._sortableGrid) {
|
||
this._sortableGrid.destroy();
|
||
this._sortableGrid = null;
|
||
}
|
||
|
||
this._sortableGrid = Sortable.create(grid, {
|
||
animation: 200,
|
||
handle: '.card-drag-handle',
|
||
draggable: '.app-stats-card.dynamic-card',
|
||
ghostClass: 'sortable-ghost',
|
||
chosenClass: 'sortable-chosen',
|
||
filter: '.app-stats-card:not(.dynamic-card), .app-stats-card-wrapper, .app-group',
|
||
onEnd: function() {
|
||
self._collectGridOrder();
|
||
}
|
||
});
|
||
},
|
||
|
||
// ─── SortableJS for List (group-level drag) ───────────────────────
|
||
_initListSortable: function(grid) {
|
||
if (typeof Sortable === 'undefined') return;
|
||
var self = this;
|
||
|
||
if (this._sortableGrid) {
|
||
this._sortableGrid.destroy();
|
||
this._sortableGrid = null;
|
||
}
|
||
|
||
this._sortableGrid = Sortable.create(grid, {
|
||
animation: 200,
|
||
handle: '.group-drag-handle',
|
||
draggable: '.app-group',
|
||
ghostClass: 'sortable-ghost',
|
||
chosenClass: 'sortable-chosen',
|
||
onEnd: function() {
|
||
self._collectListOrder();
|
||
}
|
||
});
|
||
},
|
||
|
||
// ─── Number Formatting / Animation ────────────────────────────────
|
||
parseFormattedNumber: function(formattedStr) {
|
||
if (!formattedStr || typeof formattedStr !== 'string') return 0;
|
||
var cleanStr = formattedStr.replace(/[^\d.-]/g, '');
|
||
var parsed = parseInt(cleanStr);
|
||
if (formattedStr.indexOf('K') !== -1) return Math.floor(parsed * 1000);
|
||
if (formattedStr.indexOf('M') !== -1) return Math.floor(parsed * 1000000);
|
||
return isNaN(parsed) ? 0 : Math.max(0, parsed);
|
||
},
|
||
|
||
animateNumber: function(element, start, end) {
|
||
start = Math.max(0, parseInt(start) || 0);
|
||
end = Math.max(0, parseInt(end) || 0);
|
||
if (start === end) { element.textContent = this.formatLargeNumber(end); return; }
|
||
var self = this;
|
||
var duration = 600;
|
||
var startTime = performance.now();
|
||
var updateNumber = function(currentTime) {
|
||
var elapsed = currentTime - startTime;
|
||
var progress = Math.min(elapsed / duration, 1);
|
||
var easeOutQuad = progress * (2 - progress);
|
||
var currentValue = Math.max(0, Math.floor(start + (end - start) * easeOutQuad));
|
||
element.textContent = self.formatLargeNumber(currentValue);
|
||
if (progress < 1) {
|
||
element.animationFrame = requestAnimationFrame(updateNumber);
|
||
} else {
|
||
element.textContent = self.formatLargeNumber(end);
|
||
element.animationFrame = null;
|
||
}
|
||
};
|
||
element.animationFrame = requestAnimationFrame(updateNumber);
|
||
},
|
||
|
||
formatLargeNumber: function(num) {
|
||
if (num < 1000) return num.toString();
|
||
else if (num < 10000) return (num / 1000).toFixed(1) + 'K';
|
||
else if (num < 100000) return (num / 1000).toFixed(1) + 'K';
|
||
else if (num < 1000000) return Math.floor(num / 1000) + 'K';
|
||
else if (num < 10000000) return (num / 1000000).toFixed(1) + 'M';
|
||
else if (num < 100000000) return (num / 1000000).toFixed(1) + 'M';
|
||
else if (num < 1000000000) return Math.floor(num / 1000000) + 'M';
|
||
else if (num < 10000000000) return (num / 1000000000).toFixed(1) + 'B';
|
||
else if (num < 100000000000) return (num / 1000000000).toFixed(1) + 'B';
|
||
else if (num < 1000000000000) return Math.floor(num / 1000000000) + 'B';
|
||
else return (num / 1000000000000).toFixed(1) + 'T';
|
||
},
|
||
|
||
// ─── Stats Reset ──────────────────────────────────────────────────
|
||
resetMediaStats: function(appType) {
|
||
var confirmMessage = appType
|
||
? 'Are you sure you want to reset all ' + (appType.charAt(0).toUpperCase() + appType.slice(1)) + ' statistics? This will clear all tracked hunted and upgraded items.'
|
||
: 'Are you sure you want to reset ALL statistics for ALL apps? This cannot be undone.';
|
||
var self = this;
|
||
var doReset = function() {
|
||
var endpoint = './api/stats/reset';
|
||
var body = appType ? JSON.stringify({ app_type: appType }) : '{}';
|
||
HuntarrUtils.fetchWithTimeout(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body
|
||
})
|
||
.then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })
|
||
.then(function(result) {
|
||
if (result.ok && result.data && result.data.success) {
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
var msg = appType
|
||
? (appType.charAt(0).toUpperCase() + appType.slice(1)) + ' statistics reset successfully'
|
||
: 'All statistics reset successfully';
|
||
window.huntarrUI.showNotification(msg, 'success');
|
||
}
|
||
self.loadMediaStats(true);
|
||
} else {
|
||
var errMsg = (result.data && result.data.error) ? result.data.error : 'Failed to reset statistics';
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification(errMsg, 'error');
|
||
}
|
||
}
|
||
})
|
||
.catch(function(error) {
|
||
console.error('Error resetting statistics:', error);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Error resetting statistics', 'error');
|
||
}
|
||
});
|
||
};
|
||
if (window.HuntarrConfirm && window.HuntarrConfirm.show) {
|
||
window.HuntarrConfirm.show({ title: 'Reset Statistics', message: confirmMessage, confirmLabel: 'Reset', onConfirm: doReset });
|
||
} else {
|
||
if (!confirm(confirmMessage)) return;
|
||
doReset();
|
||
}
|
||
},
|
||
|
||
// ─── Dashboard Layout Reset ───────────────────────────────────────
|
||
resetDashboardLayout: function() {
|
||
HuntarrUtils.setUIPreference('dashboard-layout', null);
|
||
HuntarrUtils.setUIPreference('dashboard-view-mode', 'list');
|
||
this._currentViewMode = 'list';
|
||
this._clearDynamicContent();
|
||
// Reset toggle
|
||
var toggleGroup = document.getElementById('dashboard-view-toggle');
|
||
if (toggleGroup) {
|
||
toggleGroup.querySelectorAll('.view-toggle-btn').forEach(function(b) {
|
||
b.classList.toggle('active', b.getAttribute('data-view') === 'grid');
|
||
});
|
||
}
|
||
if (window.mediaStats) this.updateStatsDisplay(window.mediaStats);
|
||
if (window.huntarrUI && window.huntarrUI.showNotification) {
|
||
window.huntarrUI.showNotification('Dashboard layout reset to defaults', 'success');
|
||
}
|
||
},
|
||
|
||
// ─── App Connection Checks ────────────────────────────────────────
|
||
checkAppConnections: function() {
|
||
if (!window.huntarrUI) return;
|
||
var self = this;
|
||
var apps = ['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
|
||
var checkPromises = apps.map(function(app) { return self.checkAppConnection(app); });
|
||
Promise.all(checkPromises)
|
||
.then(function() {
|
||
window.huntarrUI.configuredAppsInitialized = true;
|
||
self.updateEmptyStateVisibility();
|
||
})
|
||
.catch(function() {
|
||
window.huntarrUI.configuredAppsInitialized = true;
|
||
self.updateEmptyStateVisibility();
|
||
});
|
||
},
|
||
|
||
checkAppConnection: function(app) {
|
||
var self = this;
|
||
return HuntarrUtils.fetchWithTimeout('./api/status/' + app)
|
||
.then(function(response) { return response.json(); })
|
||
.then(function(data) {
|
||
self.updateConnectionStatus(app, data);
|
||
var isConfigured = data.configured === true;
|
||
if (['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'].indexOf(app) !== -1) {
|
||
isConfigured = (data.total_configured || 0) > 0;
|
||
}
|
||
if (window.huntarrUI) window.huntarrUI.configuredApps[app] = isConfigured;
|
||
})
|
||
.catch(function(error) {
|
||
console.error('Error checking ' + app + ' connection:', error);
|
||
self.updateConnectionStatus(app, { configured: false, connected: false });
|
||
if (window.huntarrUI) window.huntarrUI.configuredApps[app] = false;
|
||
});
|
||
},
|
||
|
||
updateConnectionStatus: function(app, statusData) {
|
||
if (!window.huntarrUI) return;
|
||
var statusElement = (window.huntarrUI.elements && window.huntarrUI.elements[app + 'HomeStatus']) || null;
|
||
if (!statusElement) {
|
||
var card = document.querySelector('.app-stats-card[data-app="' + app + '"]');
|
||
statusElement = card ? card.querySelector('.status-container .status-badge') : null;
|
||
}
|
||
if (!statusElement) return;
|
||
|
||
var isConfigured = statusData && statusData.configured === true;
|
||
var isConnected = statusData && statusData.connected === true;
|
||
var connectedCount = (statusData && statusData.connected_count) || 0;
|
||
var totalConfigured = (statusData && statusData.total_configured) || 0;
|
||
|
||
if (['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'].indexOf(app) !== -1) {
|
||
isConfigured = totalConfigured > 0;
|
||
isConnected = isConfigured && connectedCount > 0;
|
||
}
|
||
|
||
var card = statusElement.closest('.app-stats-card');
|
||
var statusContainer = statusElement.closest('.status-container');
|
||
var wrapper = card ? card.closest('.app-stats-card-wrapper') : null;
|
||
var container = wrapper || card;
|
||
if (isConfigured) {
|
||
if (container) container.style.display = '';
|
||
if (wrapper) wrapper.querySelectorAll('.app-stats-card').forEach(function(c) { c.style.display = ''; });
|
||
if (statusContainer) statusContainer.style.display = '';
|
||
} else {
|
||
if (container) container.style.display = 'none';
|
||
if (card) card.style.display = 'none';
|
||
statusElement.className = 'status-badge not-configured';
|
||
statusElement.innerHTML = '<i class="fas fa-times-circle"></i> Not Configured';
|
||
return;
|
||
}
|
||
|
||
if (['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'].indexOf(app) !== -1) {
|
||
statusElement.innerHTML = '<i class="fas fa-plug"></i> Connected ' + connectedCount + '/' + totalConfigured;
|
||
statusElement.className = 'status-badge ' + (isConnected ? 'connected' : 'error');
|
||
} else {
|
||
if (isConnected) {
|
||
statusElement.className = 'status-badge connected';
|
||
statusElement.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
|
||
} else {
|
||
statusElement.className = 'status-badge not-connected';
|
||
statusElement.innerHTML = '<i class="fas fa-times-circle"></i> Not Connected';
|
||
}
|
||
}
|
||
},
|
||
|
||
// ─── NZB Hunt Home Status Bar ──────────────────────────────────
|
||
_nzbHomePollTimer: null,
|
||
|
||
_checkNzbHuntWarning: function() {
|
||
var banner = document.getElementById('nzb-hunt-home-warning');
|
||
if (!banner) return;
|
||
// Banner is visible by default in HTML; only hide when API confirms servers exist
|
||
fetch('./api/nzb-hunt/home-stats?t=' + Date.now())
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
banner.style.display = (data.show_nzb_warning === true || data.has_servers !== true) ? 'flex' : 'none';
|
||
})
|
||
.catch(function() {
|
||
/* keep visible on error - user has no servers until we know otherwise */
|
||
});
|
||
// Retry after 1.5s in case API was not ready
|
||
setTimeout(function() {
|
||
if (!banner) return;
|
||
fetch('./api/nzb-hunt/home-stats?t=' + Date.now())
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
banner.style.display = (data.show_nzb_warning === true || data.has_servers !== true) ? 'flex' : 'none';
|
||
})
|
||
.catch(function() {});
|
||
}, 1500);
|
||
},
|
||
|
||
_fetchNzbHuntHomeStats: function() {
|
||
var card = document.getElementById('nzb-hunt-home-card');
|
||
if (!card) return;
|
||
// Hide if Media Hunt is disabled (NZB Hunt is under Media Hunt umbrella)
|
||
var ui = window.huntarrUI || {};
|
||
if (ui._enableMediaHunt === false) {
|
||
card.style.display = 'none';
|
||
this._stopNzbHomePoll();
|
||
return;
|
||
}
|
||
var self = this;
|
||
|
||
// First check visibility setting
|
||
fetch('./api/nzb-hunt/home-stats?t=' + Date.now())
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.visible) {
|
||
card.style.display = 'none';
|
||
self._stopNzbHomePoll();
|
||
return;
|
||
}
|
||
card.style.display = '';
|
||
// Fetch full status for the status bar
|
||
self._fetchNzbHuntStatus();
|
||
// Start polling if not already
|
||
self._startNzbHomePoll();
|
||
})
|
||
.catch(function() {
|
||
if (card) card.style.display = 'none';
|
||
});
|
||
},
|
||
|
||
_fetchNzbHuntStatus: function() {
|
||
var card = document.getElementById('nzb-hunt-home-card');
|
||
if (!card || card.style.display === 'none') return;
|
||
|
||
fetch('./api/nzb-hunt/status?t=' + Date.now())
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(status) {
|
||
// Connections
|
||
var connEl = document.getElementById('nzb-home-connections');
|
||
if (connEl) {
|
||
var connStats = status.connection_stats || [];
|
||
var totalActive = connStats.reduce(function(s, c) { return s + (c.active || 0); }, 0);
|
||
var totalMax = connStats.reduce(function(s, c) { return s + (c.max || 0); }, 0);
|
||
connEl.textContent = totalMax > 0 ? totalActive + ' / ' + totalMax : String(totalActive);
|
||
}
|
||
// Speed
|
||
var speedEl = document.getElementById('nzb-home-speed');
|
||
if (speedEl) speedEl.textContent = status.speed_human || '0 B/s';
|
||
// ETA
|
||
var etaEl = document.getElementById('nzb-home-eta');
|
||
if (etaEl) etaEl.textContent = status.eta_human || '--';
|
||
// Remaining
|
||
var remainEl = document.getElementById('nzb-home-remaining');
|
||
if (remainEl) remainEl.textContent = status.remaining_human || '0 B';
|
||
// Space
|
||
var spaceEl = document.getElementById('nzb-home-space');
|
||
if (spaceEl) spaceEl.textContent = status.free_space_human || '--';
|
||
// Pause button state
|
||
var pauseBtn = document.getElementById('nzb-home-pause-btn');
|
||
if (pauseBtn && status.paused_global !== undefined) {
|
||
var icon = pauseBtn.querySelector('i');
|
||
if (icon) icon.className = status.paused_global ? 'fas fa-play' : 'fas fa-pause';
|
||
pauseBtn.title = status.paused_global ? 'Resume all downloads' : 'Pause all downloads';
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[HuntarrStats] NZB Hunt status fetch error:', err);
|
||
});
|
||
},
|
||
|
||
_startNzbHomePoll: function() {
|
||
if (this._nzbHomePollTimer) return; // already polling
|
||
var self = this;
|
||
// Poll every 5 seconds for home page status
|
||
this._nzbHomePollTimer = setInterval(function() {
|
||
self._fetchNzbHuntStatus();
|
||
}, 5000);
|
||
},
|
||
|
||
_stopNzbHomePoll: function() {
|
||
if (this._nzbHomePollTimer) {
|
||
clearInterval(this._nzbHomePollTimer);
|
||
this._nzbHomePollTimer = null;
|
||
}
|
||
},
|
||
|
||
_initNzbHomePauseBtn: function() {
|
||
var btn = document.getElementById('nzb-home-pause-btn');
|
||
if (!btn || btn._nzbBound) return;
|
||
btn._nzbBound = true;
|
||
btn.addEventListener('click', function() {
|
||
var icon = btn.querySelector('i');
|
||
var isPaused = icon && icon.classList.contains('fa-play');
|
||
var endpoint = isPaused ? './api/nzb-hunt/queue/resume-all' : './api/nzb-hunt/queue/pause-all';
|
||
fetch(endpoint, { method: 'POST' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function() {
|
||
// Flip the icon immediately for responsiveness
|
||
if (icon) {
|
||
icon.className = isPaused ? 'fas fa-pause' : 'fas fa-play';
|
||
btn.title = isPaused ? 'Pause all downloads' : 'Resume all downloads';
|
||
}
|
||
})
|
||
.catch(function() {});
|
||
});
|
||
},
|
||
|
||
updateEmptyStateVisibility: function(forceShowGrid) {
|
||
if (!window.huntarrUI) return;
|
||
|
||
// Don't evaluate until settings have loaded — prevents flash of wrong state
|
||
if (!window.huntarrUI._settingsLoaded && !forceShowGrid) return;
|
||
|
||
// If we don't have a final answer on configuration yet and aren't forcing the grid, stay quiet
|
||
if (!window.huntarrUI.configuredAppsInitialized && !forceShowGrid) return;
|
||
|
||
var ui = window.huntarrUI;
|
||
var mediaHuntApps = ['movie_hunt', 'tv_hunt'];
|
||
var thirdPartyApps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
|
||
|
||
// Check if any ENABLED app is configured
|
||
var anyConfigured = false;
|
||
if (ui.configuredApps) {
|
||
Object.keys(ui.configuredApps).forEach(function(app) {
|
||
if (!ui.configuredApps[app]) return;
|
||
// Skip disabled categories
|
||
if (mediaHuntApps.indexOf(app) !== -1 && ui._enableMediaHunt === false) return;
|
||
if (thirdPartyApps.indexOf(app) !== -1 && ui._enableThirdPartyApps === false) return;
|
||
anyConfigured = true;
|
||
});
|
||
}
|
||
|
||
// If we are forcing the grid (from cache), check if any enabled category exists
|
||
if (forceShowGrid && !anyConfigured) {
|
||
// Don't force-show if all categories are disabled
|
||
var hasEnabledCategory = (ui._enableMediaHunt !== false) || (ui._enableThirdPartyApps !== false);
|
||
if (hasEnabledCategory) anyConfigured = true;
|
||
}
|
||
|
||
var emptyState = document.getElementById('live-hunts-empty-state');
|
||
var statsGrid = document.getElementById('app-stats-grid') || document.querySelector('.app-stats-grid');
|
||
|
||
if (anyConfigured) {
|
||
if (emptyState) emptyState.style.display = 'none';
|
||
if (statsGrid) statsGrid.style.display = '';
|
||
} else {
|
||
// Only show empty state if we're CERTAIN nothing is configured (or all are disabled)
|
||
if (window.huntarrUI.configuredAppsInitialized || forceShowGrid) {
|
||
// Update empty state buttons based on what's enabled
|
||
this._updateEmptyStateButtons();
|
||
if (emptyState) emptyState.style.display = 'flex';
|
||
if (statsGrid) statsGrid.style.display = 'none';
|
||
}
|
||
}
|
||
},
|
||
|
||
_updateEmptyStateButtons: function() {
|
||
var emptyState = document.getElementById('live-hunts-empty-state');
|
||
if (!emptyState) return;
|
||
var ui = window.huntarrUI || {};
|
||
var mediaEnabled = ui._enableMediaHunt !== false;
|
||
var appsEnabled = ui._enableThirdPartyApps !== false;
|
||
|
||
// Update the message text
|
||
var msgEl = emptyState.querySelector('p:nth-of-type(2)');
|
||
if (msgEl) {
|
||
if (!mediaEnabled && !appsEnabled) {
|
||
msgEl.textContent = 'Media Hunt and 3rd Party Apps are disabled. Enable them in Settings to get started.';
|
||
} else if (!mediaEnabled) {
|
||
msgEl.textContent = 'Get started by configuring your 3rd Party Apps, or enable Media Hunt in Settings.';
|
||
} else if (!appsEnabled) {
|
||
msgEl.textContent = 'Get started by heading to Media Hunt, or enable 3rd Party Apps in Settings.';
|
||
} else {
|
||
msgEl.textContent = 'Get started by heading to Media Hunt or configure your 3rd Party Apps.';
|
||
}
|
||
}
|
||
|
||
// Update button visibility
|
||
var btns = emptyState.querySelectorAll('.action-button');
|
||
var separator = emptyState.querySelector('span');
|
||
// btns[0] = Media Hunt, btns[1] = 3rd Party Apps
|
||
if (btns.length >= 2) {
|
||
btns[0].style.display = mediaEnabled ? '' : 'none';
|
||
btns[1].style.display = appsEnabled ? '' : 'none';
|
||
if (separator) separator.style.display = (mediaEnabled && appsEnabled) ? '' : 'none';
|
||
// If both disabled, show a Settings button instead
|
||
if (!mediaEnabled && !appsEnabled) {
|
||
btns[0].style.display = '';
|
||
btns[0].innerHTML = '<i class="fas fa-cog" style="margin-right: 8px;"></i> Settings';
|
||
btns[0].setAttribute('onclick', "window.location.hash = '#settings'");
|
||
if (separator) separator.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
/* === modules/ui/api-progress.js === */
|
||
/**
|
||
* API Progress Bar Enhancement
|
||
* Connects to the existing hourly-cap system to show real API usage data
|
||
*/
|
||
|
||
function updateApiProgressForCard(card, used, total) {
|
||
const safeTotal = total > 0 ? total : 20;
|
||
const percentage = (used / safeTotal) * 100;
|
||
let gradient;
|
||
if (percentage <= 35) gradient = '#22c55e';
|
||
else if (percentage <= 50) gradient = `linear-gradient(90deg, #22c55e 0%, #22c55e ${35 * 100 / percentage}%, #f59e0b 100%)`;
|
||
else if (percentage <= 70) gradient = `linear-gradient(90deg, #22c55e 0%, #22c55e ${35 * 100 / percentage}%, #f59e0b ${50 * 100 / percentage}%, #ea580c 100%)`;
|
||
else gradient = `linear-gradient(90deg, #22c55e 0%, #22c55e ${35 * 100 / percentage}%, #f59e0b ${50 * 100 / percentage}%, #ea580c ${70 * 100 / percentage}%, #ef4444 100%)`;
|
||
const progressFill = card.querySelector('.api-progress-fill');
|
||
const spans = card.querySelectorAll('.api-progress-text span');
|
||
const usedSpan = spans[0];
|
||
const totalSpan = spans[1];
|
||
if (progressFill && usedSpan && totalSpan) {
|
||
progressFill.style.width = `${percentage}%`;
|
||
progressFill.style.background = gradient;
|
||
usedSpan.textContent = used;
|
||
totalSpan.textContent = safeTotal;
|
||
}
|
||
}
|
||
|
||
function updateApiProgress(appName, used, total) {
|
||
const cards = document.querySelectorAll('.app-stats-card.' + appName);
|
||
cards.forEach(card => updateApiProgressForCard(card, used, total));
|
||
}
|
||
|
||
function syncProgressBarsWithApiCounts() {
|
||
const apps = ['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
|
||
apps.forEach(app => {
|
||
const cards = document.querySelectorAll('.app-stats-card.' + app);
|
||
cards.forEach(card => {
|
||
const countEl = card.querySelector('.hourly-cap-text span');
|
||
const limitEl = card.querySelectorAll('.hourly-cap-text span')[1];
|
||
if (countEl && limitEl) {
|
||
const used = parseInt(countEl.textContent, 10) || 0;
|
||
const total = parseInt(limitEl.textContent, 10) || 20;
|
||
updateApiProgressForCard(card, used, total);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Initialize when page loads
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Initial sync with existing API count data
|
||
syncProgressBarsWithApiCounts();
|
||
|
||
// Watch each card's count/limit (hourly-cap.js updates them); sync that card's bar when changed
|
||
const apps = ['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
|
||
apps.forEach(app => {
|
||
document.querySelectorAll('.app-stats-card.' + app).forEach(card => {
|
||
const countEl = card.querySelector('.hourly-cap-text span');
|
||
const limitEl = card.querySelectorAll('.hourly-cap-text span')[1];
|
||
if (!countEl || !limitEl) return;
|
||
const sync = () => {
|
||
const used = parseInt(countEl.textContent, 10) || 0;
|
||
const total = parseInt(limitEl.textContent, 10) || 20;
|
||
updateApiProgressForCard(card, used, total);
|
||
};
|
||
const obs = new MutationObserver(sync);
|
||
obs.observe(countEl, { childList: true, characterData: true, subtree: true });
|
||
obs.observe(limitEl, { childList: true, characterData: true, subtree: true });
|
||
});
|
||
});
|
||
|
||
// Also sync every 2 minutes (same as hourly-cap.js polling)
|
||
setInterval(syncProgressBarsWithApiCounts, 120000);
|
||
});
|
||
|
||
// Export function for external use
|
||
window.updateApiProgress = updateApiProgress;
|
||
window.syncProgressBarsWithApiCounts = syncProgressBarsWithApiCounts;
|
||
|
||
/* === modules/ui/cycle-countdown.js === */
|
||
/**
|
||
* Cycle Countdown Timer
|
||
* Shows countdown timers for each app's next cycle
|
||
*/
|
||
|
||
window.CycleCountdown = (function() {
|
||
// Cache for next cycle timestamps
|
||
const nextCycleTimes = {};
|
||
// Active timer intervals
|
||
const timerIntervals = {};
|
||
// Track apps that are currently running cycles
|
||
const runningCycles = {};
|
||
// Track instances that have a pending reset (show "Pending Reset" until cycle ends and sleep starts)
|
||
const pendingResets = {};
|
||
// Per-instance cycle activity (e.g. "Season Search (360/600)" or "Processing missing") when running
|
||
const cycleActivities = {};
|
||
// List of apps to track (movie_hunt, tv_hunt first so they appear first when configured)
|
||
const trackedApps = ['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'whisparr-v3', 'eros', 'swaparr'];
|
||
|
||
function getBaseUrl() {
|
||
return (window.HUNTARR_BASE_URL || '');
|
||
}
|
||
|
||
function buildUrl(path) {
|
||
const base = getBaseUrl();
|
||
path = path.replace(/^\.\//, '');
|
||
if (!path.startsWith('/')) {
|
||
path = '/' + path;
|
||
}
|
||
return base + path;
|
||
}
|
||
|
||
// Set up timer elements in the DOM
|
||
function setupTimerElements() {
|
||
// Create timer elements in each app status card
|
||
trackedApps.forEach(app => {
|
||
createTimerElement(app);
|
||
});
|
||
}
|
||
|
||
// Initialize countdown timers for all apps
|
||
function initialize() {
|
||
// Clear any existing running cycle and pending reset states
|
||
Object.keys(runningCycles).forEach(app => {
|
||
runningCycles[app] = false;
|
||
});
|
||
Object.keys(pendingResets).forEach(k => { delete pendingResets[k]; });
|
||
|
||
// Get references to all HTML elements
|
||
setupTimerElements();
|
||
|
||
// Set up event listeners for reset buttons
|
||
setupResetButtonListeners();
|
||
|
||
// First try to fetch from API
|
||
fetchAllCycleData()
|
||
.then((data) => {
|
||
// Success - data is processed in fetchAllCycleData
|
||
})
|
||
.catch((error) => {
|
||
console.warn('[CycleCountdown] Initial data fetch failed:', error.message);
|
||
// Show waiting message in the UI if initial load fails
|
||
displayWaitingForCycle();
|
||
});
|
||
|
||
function startRefreshInterval() {
|
||
// Clear any existing interval
|
||
if (dataRefreshIntervalId) {
|
||
clearInterval(dataRefreshIntervalId);
|
||
dataRefreshIntervalId = null;
|
||
}
|
||
|
||
// Set up API sync every 15 seconds so countdown appears soon after cycle ends (when backend sets next_cycle)
|
||
dataRefreshIntervalId = setInterval(() => {
|
||
// Only refresh if not already fetching
|
||
if (!isFetchingData) {
|
||
fetchAllCycleData()
|
||
.then(() => {})
|
||
.catch(() => {});
|
||
}
|
||
}, 15000); // API sync every 15 seconds so "Starting Cycle" updates to countdown soon after sleep starts
|
||
|
||
}
|
||
|
||
// Start the refresh cycle
|
||
startRefreshInterval();
|
||
}
|
||
|
||
// Simple lock to prevent concurrent fetches
|
||
let isFetchingData = false;
|
||
// 15-second API refresh interval (stored so cleanup can clear it)
|
||
let dataRefreshIntervalId = null;
|
||
// Poll when "Starting Cycle" is shown so countdown appears soon after sleep starts
|
||
let startingCyclePollTimeout = null;
|
||
let startingCyclePollAttempts = 0;
|
||
const STARTING_CYCLE_POLL_INTERVAL_MS = 2000;
|
||
const STARTING_CYCLE_POLL_MAX_ATTEMPTS = 15; // 2s * 15 = 30s max
|
||
|
||
function startStartingCyclePolling() {
|
||
if (startingCyclePollTimeout) return; // already polling
|
||
startingCyclePollAttempts = 0;
|
||
function poll() {
|
||
startingCyclePollAttempts++;
|
||
if (startingCyclePollAttempts > STARTING_CYCLE_POLL_MAX_ATTEMPTS) {
|
||
startingCyclePollTimeout = null;
|
||
return;
|
||
}
|
||
if (isFetchingData) {
|
||
startingCyclePollTimeout = safeSetTimeout(poll, STARTING_CYCLE_POLL_INTERVAL_MS);
|
||
return;
|
||
}
|
||
fetchAllCycleData()
|
||
.then((data) => {
|
||
const stillStarting = data && Object.keys(data).some(app => {
|
||
const appData = data[app];
|
||
if (!appData) return false;
|
||
if (appData.instances) {
|
||
return Object.keys(appData.instances).some(instName => {
|
||
const inst = appData.instances[instName];
|
||
return inst && !inst.next_cycle && !inst.cyclelock;
|
||
});
|
||
}
|
||
return (appData.next_cycle == null && !appData.cyclelock);
|
||
});
|
||
if (stillStarting && startingCyclePollAttempts < STARTING_CYCLE_POLL_MAX_ATTEMPTS) {
|
||
startingCyclePollTimeout = safeSetTimeout(poll, STARTING_CYCLE_POLL_INTERVAL_MS);
|
||
} else {
|
||
startingCyclePollTimeout = null;
|
||
}
|
||
})
|
||
.catch(() => {
|
||
startingCyclePollTimeout = safeSetTimeout(poll, STARTING_CYCLE_POLL_INTERVAL_MS);
|
||
});
|
||
}
|
||
startingCyclePollTimeout = safeSetTimeout(poll, STARTING_CYCLE_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
// Track active reset polling intervals so we don't stack them
|
||
const activeResetPolls = {};
|
||
|
||
// Set up reset button click listeners (event delegation for dynamically cloned cards)
|
||
function setupResetButtonListeners() {
|
||
// Use event delegation on document so cloned per-instance cards also get handled
|
||
document.addEventListener('click', function(e) {
|
||
const button = e.target.matches('.cycle-reset-button') ? e.target : e.target.closest('.cycle-reset-button');
|
||
if (!button) return;
|
||
|
||
const app = button.getAttribute('data-app');
|
||
const instanceName = button.getAttribute('data-instance-name') || null;
|
||
if (app) {
|
||
const key = stateKey(app, instanceName);
|
||
// Set pending reset locally for instant UI feedback
|
||
pendingResets[key] = true;
|
||
|
||
// Update timer display immediately — shows "Pending Reset" (orange)
|
||
updateTimerDisplay(app);
|
||
|
||
// Fetch latest data after a short delay so API has recorded the reset
|
||
setTimeout(function() {
|
||
fetchAllCycleData().catch(function() {});
|
||
}, 500);
|
||
|
||
// Start faster polling until reset is complete
|
||
startResetPolling(app, instanceName);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Poll more frequently after a reset until new data is available
|
||
function startResetPolling(app, instanceName) {
|
||
const key = stateKey(app, instanceName);
|
||
|
||
// Clear any existing polling for this key
|
||
if (activeResetPolls[key]) {
|
||
clearInterval(activeResetPolls[key]);
|
||
delete activeResetPolls[key];
|
||
}
|
||
|
||
let pollAttempts = 0;
|
||
const maxPollAttempts = 90; // Poll for up to 3 minutes (90 * 2 seconds)
|
||
|
||
const pollInterval = setInterval(() => {
|
||
pollAttempts++;
|
||
|
||
fetchAllCycleData()
|
||
.then(() => {
|
||
// Reset is complete when backend says pending_reset is false
|
||
// and we have a new countdown time (cycle restarted and is sleeping)
|
||
const resetDone = !pendingResets[key];
|
||
const hasCountdown = !!nextCycleTimes[key];
|
||
const isRunning = !!runningCycles[key];
|
||
|
||
if (resetDone && (hasCountdown || isRunning)) {
|
||
clearInterval(pollInterval);
|
||
delete activeResetPolls[key];
|
||
updateTimerDisplay(app);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
|
||
if (pollAttempts >= maxPollAttempts) {
|
||
clearInterval(pollInterval);
|
||
delete activeResetPolls[key];
|
||
// Clear the local pending state so normal display resumes
|
||
pendingResets[key] = false;
|
||
updateTimerDisplay(app);
|
||
}
|
||
}, 2000); // Poll every 2 seconds for fast feedback
|
||
|
||
activeResetPolls[key] = pollInterval;
|
||
}
|
||
|
||
// Display initial loading message in the UI when sleep data isn't available yet
|
||
function displayWaitingForCycle() {
|
||
trackedApps.forEach(app => {
|
||
if (!nextCycleTimes[app]) {
|
||
getTimerElements(app).forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (timerValue && (timerValue.textContent === '--:--:--' || timerValue.textContent === 'Starting Cycle')) {
|
||
timerValue.textContent = 'Waiting for Cycle';
|
||
timerValue.classList.add('refreshing-state');
|
||
timerValue.style.color = '#00c2ce';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Replace any "Loading..." timers with "Starting Cycle" so we never leave them stuck
|
||
function clearStaleLoadingTimers() {
|
||
trackedApps.forEach(app => {
|
||
getTimerElements(app).forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (timerValue && timerValue.textContent === 'Loading...') {
|
||
timerValue.textContent = 'Starting Cycle';
|
||
timerValue.classList.remove('refreshing-state');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Return all timer elements for an app (grid cards AND list-mode rows)
|
||
// Excludes timers inside hidden (old static) cards.
|
||
function getTimerElements(app) {
|
||
var results = [];
|
||
// Grid mode: timers inside VISIBLE .app-stats-card (dynamic-card only)
|
||
document.querySelectorAll('.app-stats-card.dynamic-card.' + app + ' .cycle-timer').forEach(function(t) {
|
||
results.push(t);
|
||
});
|
||
// Also check swaparr/eros cards that may not be dynamic
|
||
document.querySelectorAll('.swaparr-stats-grid .app-stats-card.' + app + ' .cycle-timer').forEach(function(t) {
|
||
if (results.indexOf(t) === -1) results.push(t);
|
||
});
|
||
// List mode: timers inside <tr> within a list table belonging to this app group
|
||
document.querySelectorAll('.app-group[data-app="' + app + '"] .cycle-timer').forEach(function(t) {
|
||
if (results.indexOf(t) === -1) results.push(t);
|
||
});
|
||
return results;
|
||
}
|
||
|
||
// Get instance name for a timer (from reset button or card/row in same container)
|
||
function getInstanceNameForTimer(timerElement) {
|
||
// Grid mode — timer is inside .app-stats-card
|
||
const card = timerElement.closest('.app-stats-card');
|
||
if (card) {
|
||
const resetBtn = card.querySelector('.cycle-reset-button[data-instance-name]');
|
||
const fromBtn = resetBtn ? resetBtn.getAttribute('data-instance-name') : null;
|
||
const fromCard = card.getAttribute('data-instance-name');
|
||
return fromBtn || fromCard || null;
|
||
}
|
||
// List mode — timer is inside a <tr> with data-instance-name
|
||
const row = timerElement.closest('tr[data-instance-name]');
|
||
if (row) return row.getAttribute('data-instance-name') || null;
|
||
return null;
|
||
}
|
||
|
||
// Key for per-instance state: "app" for single-app, "app-instanceName" for *arr instances
|
||
function stateKey(app, instanceName) {
|
||
return instanceName ? app + '-' + instanceName : app;
|
||
}
|
||
|
||
// Create timer display element in each app stats card (supports multiple instance cards)
|
||
function createTimerElement(app) {
|
||
const dataApp = app;
|
||
const cssClass = app.replace(/-/g, '');
|
||
|
||
const resetButtons = document.querySelectorAll(`button.cycle-reset-button[data-app="${dataApp}"]`);
|
||
if (!resetButtons.length) return;
|
||
|
||
resetButtons.forEach(resetButton => {
|
||
// Skip if already wrapped with a timer (grid cards with baked-in timer)
|
||
const container = resetButton.closest('.reset-and-timer-container');
|
||
if (container && container.querySelector('.cycle-timer')) return;
|
||
// Skip if button is in a table cell (list mode — timer is in adjacent <td>)
|
||
if (resetButton.closest('td')) return;
|
||
|
||
const parent = resetButton.parentNode;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'reset-and-timer-container';
|
||
wrapper.style.display = 'flex';
|
||
wrapper.style.justifyContent = 'space-between';
|
||
wrapper.style.alignItems = 'center';
|
||
wrapper.style.width = '100%';
|
||
wrapper.style.marginTop = '8px';
|
||
parent.insertBefore(wrapper, resetButton);
|
||
wrapper.appendChild(resetButton);
|
||
|
||
const timerElement = document.createElement('div');
|
||
timerElement.className = 'cycle-timer inline-timer';
|
||
timerElement.innerHTML = '<i class="fas fa-clock"></i> <span class="timer-value">Starting Cycle</span>';
|
||
if (app === 'eros') timerElement.style.cssText = 'border-left: 2px solid #ff45b7 !important;';
|
||
timerElement.classList.add(cssClass);
|
||
timerElement.setAttribute('data-app-type', app);
|
||
const timerIcon = timerElement.querySelector('i');
|
||
if (timerIcon) timerIcon.classList.add(cssClass + '-icon');
|
||
wrapper.appendChild(timerElement);
|
||
});
|
||
}
|
||
|
||
// Fetch cycle times for all tracked apps
|
||
function fetchAllCycleTimes() {
|
||
// First try to get data for all apps at once
|
||
fetchAllCycleData().catch(() => {
|
||
// If that fails, fetch individually
|
||
trackedApps.forEach(app => {
|
||
fetchCycleTime(app);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Fetch cycle data for all apps at once
|
||
function fetchAllCycleData() {
|
||
// If already fetching, don't start another fetch
|
||
if (isFetchingData) {
|
||
return Promise.resolve(nextCycleTimes); // Return existing data
|
||
}
|
||
|
||
// Set the lock
|
||
isFetchingData = true;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// Use a completely relative URL approach to avoid any subpath issues
|
||
const url = buildUrl('./api/cycle/status');
|
||
|
||
fetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Cache-Control': 'no-cache',
|
||
'Pragma': 'no-cache'
|
||
}
|
||
})
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
// Release the lock
|
||
isFetchingData = false;
|
||
|
||
// Check if we got valid data
|
||
if (Object.keys(data).length === 0) {
|
||
resolve({}); // No apps configured yet
|
||
return;
|
||
}
|
||
|
||
let dataProcessed = false;
|
||
|
||
// Process the data for each app (per-instance for *arr, single for swaparr)
|
||
for (const app in data) {
|
||
if (!trackedApps.includes(app)) continue;
|
||
const appData = data[app];
|
||
if (!appData) continue;
|
||
// Per-instance format: { instances: { InstanceName: { next_cycle, cyclelock, pending_reset } } }
|
||
if (appData.instances && typeof appData.instances === 'object') {
|
||
Object.keys(pendingResets).filter(function(k) { return k === app || k.startsWith(app + '-'); }).forEach(function(k) { delete pendingResets[k]; });
|
||
for (const instanceName in appData.instances) {
|
||
const inst = appData.instances[instanceName];
|
||
if (!inst) continue;
|
||
|
||
const key = stateKey(app, instanceName);
|
||
const nextCycleTime = inst.next_cycle ? new Date(inst.next_cycle) : null;
|
||
|
||
if (nextCycleTime && !isNaN(nextCycleTime.getTime())) {
|
||
nextCycleTimes[key] = nextCycleTime;
|
||
}
|
||
|
||
runningCycles[key] = inst.cyclelock !== undefined ? inst.cyclelock : true;
|
||
pendingResets[key] = inst.pending_reset === true;
|
||
cycleActivities[key] = inst.cycle_activity || null;
|
||
dataProcessed = true;
|
||
}
|
||
runningCycles[app] = false;
|
||
updateTimerDisplay(app);
|
||
setupCountdown(app);
|
||
continue;
|
||
}
|
||
// Single-app format: { next_cycle, cyclelock, pending_reset }
|
||
if (appData.next_cycle || appData.cyclelock !== undefined) {
|
||
const nextCycleTime = appData.next_cycle ? new Date(appData.next_cycle) : null;
|
||
|
||
if (nextCycleTime && !isNaN(nextCycleTime.getTime())) {
|
||
nextCycleTimes[app] = nextCycleTime;
|
||
}
|
||
|
||
pendingResets[app] = appData.pending_reset === true;
|
||
const cyclelock = appData.cyclelock !== undefined ? appData.cyclelock : true;
|
||
runningCycles[app] = cyclelock;
|
||
if (cyclelock && !pendingResets[app]) {
|
||
getTimerElements(app).forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (timerValue) {
|
||
timerValue.textContent = 'Running Cycle';
|
||
timerValue.classList.remove('refreshing-state');
|
||
timerValue.classList.add('running-state');
|
||
timerValue.style.color = '#00ff88';
|
||
}
|
||
});
|
||
} else if (pendingResets[app]) {
|
||
getTimerElements(app).forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (timerValue) {
|
||
timerValue.textContent = 'Pending Reset';
|
||
timerValue.classList.remove('refreshing-state', 'running-state');
|
||
timerValue.classList.add('pending-reset-state');
|
||
timerValue.style.color = '#ffaa00';
|
||
}
|
||
});
|
||
} else {
|
||
updateTimerDisplay(app);
|
||
}
|
||
setupCountdown(app);
|
||
dataProcessed = true;
|
||
}
|
||
}
|
||
|
||
if (dataProcessed) {
|
||
clearStaleLoadingTimers();
|
||
// When any instance still has no next_cycle (shows "Starting Cycle"), poll every 2s until we get
|
||
// a countdown (sleep just started; backend sets next_cycle shortly)
|
||
const hasStartingCycleWithInstances = Object.keys(data).some(app => {
|
||
const appData = data[app];
|
||
if (!appData || !appData.instances) return false;
|
||
return Object.keys(appData.instances).some(instanceName => {
|
||
const inst = appData.instances[instanceName];
|
||
return inst && !inst.next_cycle && !inst.cyclelock;
|
||
});
|
||
});
|
||
const hasStartingCycleSingle = Object.keys(data).some(app => {
|
||
const appData = data[app];
|
||
if (!appData || appData.instances) return false;
|
||
return (appData.next_cycle == null && !appData.cyclelock);
|
||
});
|
||
if (hasStartingCycleWithInstances || hasStartingCycleSingle) {
|
||
startStartingCyclePolling();
|
||
}
|
||
resolve(data);
|
||
} else {
|
||
clearStaleLoadingTimers();
|
||
resolve({}); // No configured apps found
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// Release the lock
|
||
isFetchingData = false;
|
||
|
||
// Only log errors occasionally to reduce console spam
|
||
if (Math.random() < 0.1) { // Only log 10% of errors
|
||
console.warn('[CycleCountdown] Error fetching from API:', error.message);
|
||
}
|
||
|
||
// Display waiting message in UI only if we have no existing data
|
||
if (Object.keys(nextCycleTimes).length === 0) {
|
||
displayWaitingForCycle(); // Shows "Waiting for cycle..." during startup
|
||
reject(error);
|
||
} else {
|
||
// If we have existing data, just use that
|
||
resolve(nextCycleTimes);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Fetch the next cycle time for a specific app
|
||
function fetchCycleTime(app) {
|
||
try {
|
||
// Use a completely relative URL approach to avoid any subpath issues
|
||
const url = buildUrl(`./api/cycle/status/${app}`);
|
||
|
||
// Use safe timeout to avoid context issues
|
||
safeSetTimeout(() => {
|
||
fetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Cache-Control': 'no-cache',
|
||
'Pragma': 'no-cache'
|
||
}
|
||
})
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data && data.next_cycle) {
|
||
// Store next cycle time
|
||
nextCycleTimes[app] = new Date(data.next_cycle);
|
||
|
||
// Update timer display immediately
|
||
updateTimerDisplay(app);
|
||
|
||
// Set up interval to update countdown
|
||
setupCountdown(app);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error(`[CycleCountdown] Error fetching cycle time for ${app}:`, error);
|
||
updateTimerError(app);
|
||
});
|
||
}, 50);
|
||
} catch (error) {
|
||
console.error(`[CycleCountdown] Error in fetchCycleTime for ${app}:`, error);
|
||
updateTimerError(app);
|
||
}
|
||
}
|
||
|
||
// Set up countdown interval for an app
|
||
function setupCountdown(app) {
|
||
// Clear any existing interval
|
||
if (timerIntervals[app]) {
|
||
clearInterval(timerIntervals[app]);
|
||
}
|
||
|
||
// Set up new interval to update every second for smooth countdown
|
||
timerIntervals[app] = setInterval(() => {
|
||
updateTimerDisplay(app);
|
||
}, 1000); // 1-second interval for smooth countdown
|
||
|
||
}
|
||
|
||
// Update the timer display for an app (per-instance when cards have data-instance-name)
|
||
function updateTimerDisplay(app) {
|
||
const timerElements = getTimerElements(app);
|
||
if (!timerElements.length) return;
|
||
|
||
const now = new Date();
|
||
|
||
timerElements.forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (!timerValue) return;
|
||
|
||
const instanceName = getInstanceNameForTimer(timerElement);
|
||
const key = stateKey(app, instanceName);
|
||
const nextCycleTime = nextCycleTimes[key];
|
||
const isRunning = runningCycles[key];
|
||
const isPendingReset = pendingResets[key] === true;
|
||
const timeRemaining = nextCycleTime ? (nextCycleTime - now) : 0;
|
||
const isExpired = nextCycleTime && timeRemaining <= 0;
|
||
|
||
let formattedTime = 'Starting Cycle';
|
||
if (nextCycleTime && !isExpired && !isRunning && !isPendingReset) {
|
||
const hours = Math.floor(timeRemaining / (1000 * 60 * 60));
|
||
const minutes = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60));
|
||
const seconds = Math.floor((timeRemaining % (1000 * 60)) / 1000);
|
||
formattedTime = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
|
||
}
|
||
if (isExpired) delete nextCycleTimes[key];
|
||
|
||
if (isPendingReset) {
|
||
timerValue.textContent = 'Pending Reset';
|
||
timerValue.classList.remove('refreshing-state', 'running-state');
|
||
timerValue.classList.add('pending-reset-state');
|
||
timerValue.style.color = '#ffaa00';
|
||
return;
|
||
}
|
||
if (isRunning) {
|
||
const activity = cycleActivities[key];
|
||
timerValue.textContent = (activity && String(activity).trim()) ? activity : 'Running Cycle';
|
||
timerValue.classList.remove('refreshing-state', 'pending-reset-state');
|
||
timerValue.classList.add('running-state');
|
||
timerValue.style.color = '#00ff88';
|
||
return;
|
||
}
|
||
if (!nextCycleTime || isExpired) {
|
||
timerValue.textContent = 'Starting Cycle';
|
||
timerValue.classList.remove('refreshing-state', 'running-state', 'pending-reset-state');
|
||
timerValue.style.removeProperty('color');
|
||
return;
|
||
}
|
||
timerValue.textContent = formattedTime;
|
||
timerValue.classList.remove('refreshing-state', 'running-state', 'pending-reset-state');
|
||
updateTimerStyle(timerElement, timeRemaining);
|
||
});
|
||
}
|
||
|
||
// Update timer styling based on remaining time
|
||
function updateTimerStyle(timerElement, timeRemaining) {
|
||
// Get the timer value element
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (!timerValue) return;
|
||
|
||
// Remove any existing time-based classes from both elements
|
||
timerElement.classList.remove('timer-soon', 'timer-imminent', 'timer-normal');
|
||
timerValue.classList.remove('timer-value-soon', 'timer-value-imminent', 'timer-value-normal');
|
||
|
||
// Add class based on time remaining
|
||
if (timeRemaining < 60000) { // Less than 1 minute
|
||
timerElement.classList.add('timer-imminent');
|
||
timerValue.classList.add('timer-value-imminent');
|
||
timerValue.style.color = '#ff3333'; // Red - direct styling for immediate effect
|
||
} else if (timeRemaining < 300000) { // Less than 5 minutes
|
||
timerElement.classList.add('timer-soon');
|
||
timerValue.classList.add('timer-value-soon');
|
||
timerValue.style.color = '#ff8c00'; // Orange - direct styling for immediate effect
|
||
} else {
|
||
timerElement.classList.add('timer-normal');
|
||
timerValue.classList.add('timer-value-normal');
|
||
timerValue.style.color = 'white'; // White - direct styling for immediate effect
|
||
}
|
||
}
|
||
|
||
// Show error state in timer for actual errors (not startup waiting)
|
||
function updateTimerError(app) {
|
||
getTimerElements(app).forEach(timerElement => {
|
||
const timerValue = timerElement.querySelector('.timer-value');
|
||
if (timerValue) {
|
||
timerValue.textContent = 'Unavailable';
|
||
timerValue.style.color = '#ff6b6b';
|
||
timerElement.classList.add('timer-error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Clean up timers when leaving home (stops all intervals and polling)
|
||
function cleanup() {
|
||
Object.keys(timerIntervals).forEach(app => {
|
||
clearInterval(timerIntervals[app]);
|
||
delete timerIntervals[app];
|
||
});
|
||
if (dataRefreshIntervalId) {
|
||
clearInterval(dataRefreshIntervalId);
|
||
dataRefreshIntervalId = null;
|
||
}
|
||
if (startingCyclePollTimeout) {
|
||
clearTimeout(startingCyclePollTimeout);
|
||
startingCyclePollTimeout = null;
|
||
}
|
||
}
|
||
|
||
// Initialize on page load - with proper binding for setTimeout
|
||
function safeSetTimeout(callback, delay) {
|
||
// Make sure we're using the global window object for setTimeout
|
||
return window.setTimeout.bind(window)(callback, delay);
|
||
}
|
||
|
||
function safeSetInterval(callback, delay) {
|
||
// Make sure we're using the global window object for setInterval
|
||
return window.setInterval.bind(window)(callback, delay);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Skip initialization on login page or if not authenticated
|
||
const isLoginPage = document.querySelector('.login-container, #loginForm, .login-form');
|
||
if (isLoginPage) return;
|
||
|
||
// Only initialize if we're on a page that has app status cards
|
||
const homeSection = document.getElementById('homeSection');
|
||
const hasAppCards = document.querySelector('.app-status-card, .status-card, [id$="StatusCard"]');
|
||
|
||
if (!homeSection && !hasAppCards) return;
|
||
|
||
// Simple initialization with minimal delay
|
||
setTimeout(function() {
|
||
// Always initialize immediately on page load
|
||
initialize();
|
||
|
||
// Also set up observer for home section visibility changes
|
||
const observer = new MutationObserver((mutations) => {
|
||
for (const mutation of mutations) {
|
||
if (mutation.target.id === 'homeSection' &&
|
||
mutation.attributeName === 'class' &&
|
||
!mutation.target.classList.contains('hidden')) {
|
||
initialize();
|
||
} else if (mutation.target.id === 'homeSection' &&
|
||
mutation.attributeName === 'class' &&
|
||
mutation.target.classList.contains('hidden')) {
|
||
cleanup();
|
||
}
|
||
}
|
||
});
|
||
|
||
if (homeSection) {
|
||
observer.observe(homeSection, { attributes: true });
|
||
}
|
||
}, 100); // 100ms delay is enough
|
||
});
|
||
|
||
// Refresh all cycle data immediately (for timezone changes)
|
||
// When called right after list-mode render, a second delayed refresh ensures
|
||
// timers (which may not exist yet) get updated — fixes TV Hunt etc. stuck on "Loading..."
|
||
function refreshAllData() {
|
||
fetchAllCycleData()
|
||
.then(() => {
|
||
// Delayed refresh to catch timers that appeared after first fetch (list-mode race)
|
||
safeSetTimeout(() => {
|
||
fetchAllCycleData().then(clearStaleLoadingTimers).catch(() => {});
|
||
}, 500);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Public API
|
||
return {
|
||
initialize: initialize,
|
||
fetchAllCycleTimes: fetchAllCycleTimes,
|
||
cleanup: cleanup,
|
||
refreshAllData: refreshAllData,
|
||
refreshTimerElements: setupTimerElements
|
||
};
|
||
})();
|
||
|
||
|
||
/* === modules/ui/apps-scroll-fix.js === */
|
||
/**
|
||
* Apps Section Scroll Fix
|
||
* This script prevents double scrollbars and limits excessive scrolling
|
||
* by ensuring only the main content area is scrollable
|
||
*/
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Function to fix the apps section scrolling
|
||
function fixAppsScrolling() {
|
||
// Get the main content element (this should be the only scrollable container)
|
||
const mainContent = document.querySelector('.main-content');
|
||
|
||
// Get the apps section elements
|
||
const appsSection = document.getElementById('appsSection');
|
||
const singleScrollContainer = appsSection ? appsSection.querySelector('.single-scroll-container') : null;
|
||
const appPanelsContainer = appsSection ? appsSection.querySelector('.app-panels-container') : null;
|
||
|
||
// Make sure main content is the only scrollable container
|
||
if (mainContent) {
|
||
mainContent.style.overflowY = 'auto';
|
||
mainContent.style.height = '100vh';
|
||
}
|
||
|
||
// If the apps section exists, make it visible but not scrollable
|
||
if (appsSection) {
|
||
// Remove scrolling from apps section
|
||
appsSection.style.overflow = 'visible';
|
||
appsSection.style.height = 'auto';
|
||
appsSection.style.maxHeight = 'none';
|
||
|
||
// Remove scrolling from single scroll container
|
||
if (singleScrollContainer) {
|
||
singleScrollContainer.style.overflow = 'visible';
|
||
singleScrollContainer.style.height = 'auto';
|
||
singleScrollContainer.style.maxHeight = 'none';
|
||
}
|
||
|
||
// Remove excessive padding from app panels container
|
||
if (appPanelsContainer) {
|
||
appPanelsContainer.style.height = 'auto';
|
||
appPanelsContainer.style.overflow = 'visible';
|
||
appPanelsContainer.style.marginBottom = '50px';
|
||
appPanelsContainer.style.paddingBottom = '0';
|
||
}
|
||
|
||
// Remove excessive padding from all app panels
|
||
const appPanels = document.querySelectorAll('.app-apps-panel');
|
||
appPanels.forEach(panel => {
|
||
panel.style.overflow = 'visible';
|
||
panel.style.height = 'auto';
|
||
panel.style.maxHeight = 'none';
|
||
panel.style.paddingBottom = '50px';
|
||
panel.style.marginBottom = '20px';
|
||
});
|
||
|
||
// Remove excessive bottom padding from additional options sections
|
||
const additionalOptions = document.querySelectorAll('.additional-options, .skip-series-refresh');
|
||
additionalOptions.forEach(section => {
|
||
section.style.overflow = 'visible';
|
||
section.style.marginBottom = '50px';
|
||
section.style.paddingBottom = '20px';
|
||
});
|
||
|
||
// Make sure content sections are not scrollable
|
||
const contentSections = document.querySelectorAll('.content-section');
|
||
contentSections.forEach(section => {
|
||
section.style.overflow = 'visible';
|
||
section.style.height = 'auto';
|
||
});
|
||
|
||
// Make sure app container is not scrollable
|
||
const appsContainer = document.getElementById('appsContainer');
|
||
if (appsContainer) {
|
||
appsContainer.style.overflow = 'visible';
|
||
appsContainer.style.height = 'auto';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply the fix immediately
|
||
fixAppsScrolling();
|
||
|
||
// Apply after a short delay to account for dynamic content
|
||
setTimeout(fixAppsScrolling, 500);
|
||
setTimeout(fixAppsScrolling, 1000); // Additional delayed application
|
||
|
||
// Apply when app selection changes
|
||
const appsAppSelect = document.getElementById('appsAppSelect');
|
||
if (appsAppSelect) {
|
||
appsAppSelect.addEventListener('change', function() {
|
||
// Wait for panel to update
|
||
setTimeout(fixAppsScrolling, 300);
|
||
});
|
||
}
|
||
|
||
// Apply when window is resized
|
||
window.addEventListener('resize', fixAppsScrolling);
|
||
|
||
// Apply when hash changes (navigation)
|
||
window.addEventListener('hashchange', function() {
|
||
// Check if we navigated to the apps section
|
||
setTimeout(fixAppsScrolling, 300);
|
||
});
|
||
});
|
||
|
||
/* === modules/ui/card-hover-effects.js === */
|
||
/**
|
||
* Huntarr - Card Hover Effects
|
||
* Adds subtle hover animations to app cards
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Add hover effects to app cards
|
||
const appCards = document.querySelectorAll('.app-stats-card');
|
||
|
||
appCards.forEach(card => {
|
||
// Add transition properties
|
||
card.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease, filter 0.3s ease';
|
||
|
||
// Mouse enter event - elevate and highlight card
|
||
card.addEventListener('mouseenter', function() {
|
||
card.style.transform = 'translateY(-5px) scale(1.02)';
|
||
card.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.2)';
|
||
card.style.filter = 'brightness(1.1)';
|
||
|
||
// Get app type from classes
|
||
const appType = getAppType(card);
|
||
if (appType) {
|
||
// Add app-specific glow effect
|
||
const glowColors = {
|
||
'sonarr': '0 0 15px rgba(52, 152, 219, 0.4)',
|
||
'radarr': '0 0 15px rgba(243, 156, 18, 0.4)',
|
||
'lidarr': '0 0 15px rgba(46, 204, 113, 0.4)',
|
||
'readarr': '0 0 15px rgba(231, 76, 60, 0.4)',
|
||
'whisparr': '0 0 15px rgba(155, 89, 182, 0.4)',
|
||
'eros': '0 0 15px rgba(26, 188, 156, 0.4)'
|
||
};
|
||
|
||
if (glowColors[appType]) {
|
||
card.style.boxShadow += ', ' + glowColors[appType];
|
||
}
|
||
}
|
||
});
|
||
|
||
// Mouse leave event - return to normal
|
||
card.addEventListener('mouseleave', function() {
|
||
card.style.transform = 'translateY(0) scale(1)';
|
||
card.style.boxShadow = '';
|
||
card.style.filter = 'brightness(1)';
|
||
});
|
||
});
|
||
|
||
// Helper function to get app type from card classes
|
||
function getAppType(card) {
|
||
const classList = card.classList;
|
||
const appTypes = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'];
|
||
|
||
for (const type of appTypes) {
|
||
if (classList.contains(type)) {
|
||
return type;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
});
|
||
|
||
|
||
/* === modules/ui/circular-progress.js === */
|
||
/**
|
||
* Huntarr - Circular Progress Indicators
|
||
* Creates animated circular progress indicators for API usage counters
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Create and inject SVG progress indicators for API counts
|
||
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'];
|
||
|
||
// App-specific colors matching your existing design
|
||
const appColors = {
|
||
'sonarr': '#6366f1', // Indigo
|
||
'radarr': '#f39c12', // Yellow/orange
|
||
'lidarr': '#2ecc71', // Green
|
||
'readarr': '#e74c3c', // Red
|
||
'whisparr': '#9b59b6', // Purple
|
||
'eros': '#1abc9c' // Teal
|
||
};
|
||
|
||
// Add circular progress indicators to each API count indicator
|
||
apps.forEach(app => {
|
||
const capContainer = document.querySelector(`#${app}-hourly-cap`);
|
||
if (!capContainer) return;
|
||
|
||
// Get current API count and limit
|
||
const countElement = document.querySelector(`#${app}-api-count`);
|
||
const limitElement = document.querySelector(`#${app}-api-limit`);
|
||
|
||
if (!countElement || !limitElement) return;
|
||
|
||
const count = parseInt(countElement.textContent);
|
||
const limit = parseInt(limitElement.textContent);
|
||
|
||
// Create SVG container for progress circle
|
||
const svgSize = 28;
|
||
const circleRadius = 10;
|
||
const circleStrokeWidth = 2.5;
|
||
const circumference = 2 * Math.PI * circleRadius;
|
||
|
||
// Calculate progress percentage
|
||
const percentage = Math.min(count / limit, 1);
|
||
const dashOffset = circumference * (1 - percentage);
|
||
|
||
// Create SVG element
|
||
const svgNamespace = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(svgNamespace, "svg");
|
||
svg.setAttribute("width", svgSize);
|
||
svg.setAttribute("height", svgSize);
|
||
svg.setAttribute("viewBox", `0 0 ${svgSize} ${svgSize}`);
|
||
svg.classList.add("api-progress-circle");
|
||
|
||
// Background circle
|
||
const bgCircle = document.createElementNS(svgNamespace, "circle");
|
||
bgCircle.setAttribute("cx", svgSize / 2);
|
||
bgCircle.setAttribute("cy", svgSize / 2);
|
||
bgCircle.setAttribute("r", circleRadius);
|
||
bgCircle.setAttribute("fill", "none");
|
||
bgCircle.setAttribute("stroke", "rgba(255, 255, 255, 0.1)");
|
||
bgCircle.setAttribute("stroke-width", circleStrokeWidth);
|
||
|
||
// Progress circle
|
||
const progressCircle = document.createElementNS(svgNamespace, "circle");
|
||
progressCircle.setAttribute("cx", svgSize / 2);
|
||
progressCircle.setAttribute("cy", svgSize / 2);
|
||
progressCircle.setAttribute("r", circleRadius);
|
||
progressCircle.setAttribute("fill", "none");
|
||
progressCircle.setAttribute("stroke", appColors[app]);
|
||
progressCircle.setAttribute("stroke-width", circleStrokeWidth);
|
||
progressCircle.setAttribute("stroke-dasharray", circumference);
|
||
progressCircle.setAttribute("stroke-dashoffset", dashOffset);
|
||
progressCircle.setAttribute("transform", `rotate(-90 ${svgSize/2} ${svgSize/2})`);
|
||
|
||
// Add circles to SVG
|
||
svg.appendChild(bgCircle);
|
||
svg.appendChild(progressCircle);
|
||
|
||
// Add SVG before text content
|
||
capContainer.insertBefore(svg, capContainer.firstChild);
|
||
|
||
// Style for the indicator
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.api-progress-circle {
|
||
margin-right: 5px;
|
||
filter: drop-shadow(0 0 3px ${appColors[app]}40);
|
||
}
|
||
|
||
.hourly-cap-status {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.api-progress-circle circle:nth-child(2) {
|
||
filter: drop-shadow(0 0 4px ${appColors[app]}60);
|
||
transition: stroke-dashoffset 0.5s ease;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Update progress when API counts change
|
||
const updateProgressCircle = () => {
|
||
const newCount = parseInt(countElement.textContent);
|
||
const newLimit = parseInt(limitElement.textContent);
|
||
const newPercentage = Math.min(newCount / newLimit, 1);
|
||
const newDashOffset = circumference * (1 - newPercentage);
|
||
|
||
progressCircle.setAttribute("stroke-dashoffset", newDashOffset);
|
||
|
||
// Change color based on usage percentage
|
||
if (newPercentage > 0.9) {
|
||
progressCircle.setAttribute("stroke", "#e74c3c"); // Red when near limit
|
||
} else if (newPercentage > 0.75) {
|
||
progressCircle.setAttribute("stroke", "#f39c12"); // Orange/yellow for moderate usage
|
||
} else {
|
||
progressCircle.setAttribute("stroke", appColors[app]); // Default color
|
||
}
|
||
};
|
||
|
||
// Set up a mutation observer to watch for changes in the count value
|
||
const observer = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.type === 'characterData' || mutation.type === 'childList') {
|
||
updateProgressCircle();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Observe both count and limit elements
|
||
observer.observe(countElement, { characterData: true, childList: true, subtree: true });
|
||
observer.observe(limitElement, { characterData: true, childList: true, subtree: true });
|
||
});
|
||
});
|
||
|
||
|
||
/* === modules/ui/background-pattern.js === */
|
||
/**
|
||
* Huntarr - Subtle Background Pattern
|
||
* Adds a modern dot grid pattern to the dashboard background
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Add subtle background pattern styles
|
||
const style = document.createElement('style');
|
||
style.id = 'background-pattern-styles';
|
||
|
||
// Pattern style based on the user's preference for dark themes with blue accents
|
||
style.textContent = `
|
||
/* Subtle dot grid pattern for dark background */
|
||
.dashboard-grid::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-image:
|
||
radial-gradient(circle at 1px 1px, rgba(85, 97, 215, 0.07) 1px, transparent 0);
|
||
background-size: 25px 25px;
|
||
background-position: -5px -5px;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* Make sure all dashboard content stays above the pattern */
|
||
.dashboard-grid > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* For mobile - smaller pattern */
|
||
@media (max-width: 768px) {
|
||
.dashboard-grid::before {
|
||
background-size: 20px 20px;
|
||
}
|
||
}
|
||
`;
|
||
|
||
document.head.appendChild(style);
|
||
|
||
// Make sure the container has position relative for the pattern to work
|
||
const dashboardGrid = document.querySelector('.dashboard-grid');
|
||
if (dashboardGrid) {
|
||
dashboardGrid.style.position = 'relative';
|
||
dashboardGrid.style.overflow = 'hidden';
|
||
}
|
||
});
|
||
|
||
|
||
/* === modules/ui/hourly-cap.js === */
|
||
/**
|
||
* Hourly API Cap Handling for Huntarr
|
||
* Fetches and updates the hourly API usage indicators on the dashboard
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Set up polling to refresh the hourly cap data every 2 minutes
|
||
setInterval(loadHourlyCapData, 120000);
|
||
});
|
||
|
||
/**
|
||
* Load hourly API cap data from the server
|
||
*/
|
||
window.loadHourlyCapData = function loadHourlyCapData() {
|
||
HuntarrUtils.fetchWithTimeout('./api/hourly-caps')
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('Network response was not ok');
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data.success && data.caps && data.limits) {
|
||
updateHourlyCapDisplay(data.caps, data.limits);
|
||
} else {
|
||
console.error('Failed to load hourly API cap data:', data.message || 'Unknown error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error fetching hourly API cap data:', error);
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Get instance name for a card (from card attribute or reset button).
|
||
* @param {Element} card - .app-stats-card element
|
||
* @returns {string|null} Instance name or null for single-app
|
||
*/
|
||
function getInstanceNameForCard(card) {
|
||
// Check card attribute first (most reliable)
|
||
if (card.hasAttribute('data-instance-name')) {
|
||
return card.getAttribute('data-instance-name');
|
||
}
|
||
// Fallback to reset button
|
||
const resetBtn = card.querySelector('.cycle-reset-button[data-instance-name]');
|
||
return resetBtn ? resetBtn.getAttribute('data-instance-name') : null;
|
||
}
|
||
|
||
/**
|
||
* Update the hourly API cap indicators for each app (per-instance when app has instances).
|
||
* Data is keyed by instance name; fallback to index so 2nd+ instance cards always update.
|
||
* @param {Object} caps - Hourly API usage: per-app or per-instance (caps[app].instances[instanceName])
|
||
* @param {Object} limits - Limits: per-app number or per-instance (limits[app].instances[instanceName])
|
||
*/
|
||
function updateHourlyCapDisplay(caps, limits) {
|
||
const apps = ['movie_hunt', 'tv_hunt', 'sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'];
|
||
|
||
apps.forEach(app => {
|
||
if (!caps[app]) return;
|
||
const cards = document.querySelectorAll('.app-stats-card.' + app);
|
||
const hasInstances = caps[app].instances && typeof caps[app].instances === 'object';
|
||
const appLimit = typeof limits[app] === 'number' ? limits[app] : 20;
|
||
const usage = !hasInstances && caps[app].api_hits != null ? caps[app].api_hits : 0;
|
||
|
||
let instanceNames = [];
|
||
if (hasInstances && limits[app] && limits[app].instances) {
|
||
instanceNames = Object.keys(caps[app].instances);
|
||
}
|
||
|
||
cards.forEach((card, cardIndex) => {
|
||
let usageVal = usage;
|
||
let limitVal = appLimit;
|
||
if (hasInstances && instanceNames.length > 0) {
|
||
const instanceName = getInstanceNameForCard(card);
|
||
const nameToUse = instanceName != null && caps[app].instances[instanceName] != null
|
||
? instanceName
|
||
: instanceNames[cardIndex] || null;
|
||
const instCaps = nameToUse != null ? caps[app].instances[nameToUse] : null;
|
||
const instLimits = limits[app].instances && nameToUse != null ? limits[app].instances[nameToUse] : appLimit;
|
||
usageVal = instCaps && instCaps.api_hits != null ? instCaps.api_hits : 0;
|
||
limitVal = instLimits != null ? instLimits : 20;
|
||
}
|
||
const pct = (limitVal > 0) ? (usageVal / limitVal) * 100 : 0;
|
||
const countEl = card.querySelector('.hourly-cap-text span');
|
||
const limitEl = card.querySelectorAll('.hourly-cap-text span')[1];
|
||
if (countEl) countEl.textContent = usageVal;
|
||
if (limitEl) limitEl.textContent = limitVal;
|
||
const statusEl = card.querySelector('.hourly-cap-status');
|
||
if (statusEl) {
|
||
statusEl.classList.remove('good', 'warning', 'danger');
|
||
if (pct >= 100) statusEl.classList.add('danger');
|
||
else if (pct >= 75) statusEl.classList.add('warning');
|
||
else statusEl.classList.add('good');
|
||
}
|
||
const progressFill = card.querySelector('.api-progress-fill');
|
||
if (progressFill) progressFill.style.width = Math.min(100, pct) + '%';
|
||
const progressSpans = card.querySelectorAll('.api-progress-text span');
|
||
if (progressSpans.length >= 2) {
|
||
progressSpans[0].textContent = usageVal;
|
||
progressSpans[1].textContent = limitVal;
|
||
}
|
||
});
|
||
});
|
||
}
|