Files
Huntarr.io/frontend/static/js/modules/features/requestarr/requestarr-content.js
Admin9705 62e5312e33 Update
2026-02-22 09:23:45 -05:00

1722 lines
74 KiB
JavaScript

/**
* Requestarr Content - Content loading and media card creation
*/
import { encodeInstanceValue, decodeInstanceValue } from './requestarr-core.js';
export class RequestarrContent {
constructor(core) {
this.core = core;
this.moviesPage = 1;
this.moviesHasMore = true;
this.isLoadingMovies = false;
this.moviesObserver = null;
this.tvPage = 1;
this.tvHasMore = true;
this.isLoadingTV = false;
this.tvObserver = null;
this.moviesRequestToken = 0;
this.tvRequestToken = 0;
this.activeMovieInstance = null;
this.activeTVInstance = null;
// Instance tracking - unified across all Requestarr pages via server-side DB.
// Loaded once via _loadServerDefaults(), saved via _saveServerDefaults().
this.selectedMovieInstance = null;
this.selectedTVInstance = null;
this._serverDefaultsLoaded = false;
// Smart Hunt grid state
this.smarthuntPage = 0;
this.smarthuntHasMore = true;
this.isLoadingSmartHunt = false;
this.smarthuntObserver = null;
this.smarthuntRequestToken = 0;
this._smarthuntAllResults = [];
this._shFilters = null;
this._smarthuntInstancesPopulated = false;
// Hidden media tracking
this.hiddenMediaSet = new Set();
// Track whether movie/TV dropdowns have been populated (prevents race with _loadServerDefaults)
this._movieInstancesPopulated = false;
this._tvInstancesPopulated = false;
// Auto-refresh dropdowns when any instance is added/deleted/renamed anywhere in the app
document.addEventListener('huntarr:instances-changed', () => {
this.refreshInstanceSelectors();
});
}
// ========================================
// INSTANCE MANAGEMENT
// ========================================
async setupInstanceSelectors() {
// Load server defaults first, then populate selectors
await this._loadServerDefaults();
await this.loadMovieInstances();
await this.loadTVInstances();
}
/**
* Public refresh: re-fetch instance lists from the API and repopulate all
* Requestarr dropdowns (Discover + Movies/TV list pages).
* Called by navigation.js when switching to Requestarr sections so newly
* added/removed instances appear without a full page reload.
*/
async refreshInstanceSelectors() {
this._serverDefaultsLoaded = false;
this._movieInstancesPopulated = false;
this._tvInstancesPopulated = false;
this._bundleDropdownCache = null;
await this._loadServerDefaults();
await Promise.all([
this._populateDiscoverMovieInstances(),
this._populateDiscoverTVInstances()
]);
await this.loadMovieInstances();
await this.loadTVInstances();
}
// ----------------------------------------
// SERVER-SIDE INSTANCE PERSISTENCE
// ----------------------------------------
/**
* Load the saved default instances from the server (DB).
* Called once on init; populates this.selectedMovieInstance / this.selectedTVInstance.
*/
async _loadServerDefaults() {
if (this._serverDefaultsLoaded) return;
try {
const res = await fetch('./api/requestarr/settings/default-instances');
const data = await res.json();
if (data.success && data.defaults) {
this.selectedMovieInstance = data.defaults.movie_instance || null;
this.selectedTVInstance = data.defaults.tv_instance || null;
console.log('[RequestarrContent] Loaded server defaults:', data.defaults);
}
} catch (e) {
console.warn('[RequestarrContent] Could not load server defaults:', e);
}
this._serverDefaultsLoaded = true;
}
/**
* Save the current movie + TV instance to the server (fire-and-forget).
*/
_saveServerDefaults() {
return fetch('./api/requestarr/settings/default-instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
movie_instance: this.selectedMovieInstance || '',
tv_instance: this.selectedTVInstance || ''
})
}).catch(e => console.warn('[RequestarrContent] Failed to save server defaults:', e));
}
/**
* Update the movie instance in memory + server, then sync all page dropdowns.
* Returns a promise that resolves once the server save completes.
*/
async _setMovieInstance(compoundValue) {
this.selectedMovieInstance = compoundValue;
this._syncAllMovieSelectors();
await this._saveServerDefaults();
// Reload Smart Hunt carousel if active
if (this._discoverSmartHunt) this._discoverSmartHunt.reload();
}
/**
* Update the TV instance in memory + server, then sync all page dropdowns.
* Returns a promise that resolves once the server save completes.
*/
async _setTVInstance(value) {
this.selectedTVInstance = value;
this._syncAllTVSelectors();
await this._saveServerDefaults();
// Reload Smart Hunt carousel if active
if (this._discoverSmartHunt) this._discoverSmartHunt.reload();
}
/**
* Sync every movie-instance dropdown on the page to the current value.
*/
_syncAllMovieSelectors() {
const ids = ['movies-instance-select', 'discover-movie-instance-select', 'home-movie-instance-select', 'smarthunt-movie-instance-select'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el && el.value !== this.selectedMovieInstance) {
el.value = this.selectedMovieInstance;
}
});
// Also sync HomeRequestarr's in-memory default
if (window.HomeRequestarr) {
window.HomeRequestarr.defaultMovieInstance = this.selectedMovieInstance;
}
}
/**
* Sync every TV-instance dropdown on the page to the current value.
*/
_syncAllTVSelectors() {
const ids = ['tv-instance-select', 'discover-tv-instance-select', 'home-tv-instance-select', 'smarthunt-tv-instance-select'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el && el.value !== this.selectedTVInstance) {
el.value = this.selectedTVInstance;
}
});
// Also sync HomeRequestarr's in-memory default
if (window.HomeRequestarr) {
window.HomeRequestarr.defaultTVInstance = this.selectedTVInstance;
}
}
// ----------------------------------------
// DISCOVER PAGE INSTANCE SELECTORS
// ----------------------------------------
/**
* Populate the Discover page's movie + TV instance selectors and wire change events.
*/
async setupDiscoverInstances() {
await this._loadServerDefaults();
await Promise.all([
this._populateDiscoverMovieInstances(),
this._populateDiscoverTVInstances()
]);
}
/**
* Fetch bundle dropdown options from the server (cached per refresh cycle).
* Returns { movie_options, tv_options } where each option has value + label.
* The value uses appType:instanceName format so existing code works unchanged.
*/
async _fetchBundleDropdownOptions() {
if (this._bundleDropdownCache) return this._bundleDropdownCache;
try {
const resp = await fetch(`./api/requestarr/bundles/dropdown?t=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) throw new Error('Failed');
const data = await resp.json();
// Normalize: value for bundles uses primary's appType:instanceName
const normalize = (opts) => (opts || []).map(o => ({
value: o.is_bundle ? encodeInstanceValue(o.primary_app_type, o.primary_instance_name) : o.value,
label: o.label,
isBundle: o.is_bundle,
}));
this._bundleDropdownCache = {
movie_options: normalize(data.movie_options),
tv_options: normalize(data.tv_options),
};
return this._bundleDropdownCache;
} catch (e) {
console.warn('[RequestarrContent] Error fetching bundle dropdown:', e);
return { movie_options: [], tv_options: [] };
}
}
/**
* Populate a select element from bundle dropdown options.
*/
_populateSelectFromOptions(select, options, savedValue) {
select.innerHTML = '';
if (options.length === 0) {
select.innerHTML = '<option value="">No instances configured</option>';
return null;
}
let matchedValue = null;
options.forEach(opt => {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
if (savedValue && opt.value === savedValue) {
el.selected = true;
matchedValue = opt.value;
}
select.appendChild(el);
});
// If no match, select first
if (!matchedValue && options.length > 0) {
select.options[0].selected = true;
matchedValue = options[0].value;
}
return matchedValue;
}
async _populateDiscoverMovieInstances() {
const select = document.getElementById('discover-movie-instance-select');
if (!select) return;
try {
const dd = await this._fetchBundleDropdownOptions();
const previousValue = this.selectedMovieInstance || select.value || '';
const matched = this._populateSelectFromOptions(select, dd.movie_options, previousValue);
if (matched) this.selectedMovieInstance = matched;
if (!select._discoverChangeWired) {
select._discoverChangeWired = true;
select.addEventListener('change', async () => {
await this._setMovieInstance(select.value);
this.reloadDiscoverMovies();
});
}
} catch (error) {
console.error('[RequestarrContent] Error loading discover movie instances:', error);
}
}
async _populateDiscoverTVInstances() {
const select = document.getElementById('discover-tv-instance-select');
if (!select) return;
try {
const dd = await this._fetchBundleDropdownOptions();
const previousValue = this.selectedTVInstance || select.value || '';
const matched = this._populateSelectFromOptions(select, dd.tv_options, previousValue);
if (matched) this.selectedTVInstance = matched;
if (!select._discoverChangeWired) {
select._discoverChangeWired = true;
select.addEventListener('change', async () => {
await this._setTVInstance(select.value);
this.reloadDiscoverTV();
});
}
} catch (error) {
console.error('[RequestarrContent] Error loading discover TV instances:', error);
}
}
/**
* Re-fetch and render Popular Movies carousel with the current movie instance.
* Also refreshes trending since movie statuses depend on the selected instance.
*/
async reloadDiscoverMovies() {
const carousel = document.getElementById('popular-movies-carousel');
if (!carousel) return;
carousel.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading movies...</p></div>';
try {
const decoded = decodeInstanceValue(this.selectedMovieInstance);
let url = './api/requestarr/discover/movies?page=1';
if (decoded.name) url += `&app_type=${decoded.appType}&instance_name=${encodeURIComponent(decoded.name)}`;
const response = await fetch(url);
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularMoviesResults(carousel, results);
} catch (error) {
console.error('[RequestarrContent] Error reloading discover movies:', error);
}
// Refresh trending with updated instance params (status badges depend on selected instance)
await this.loadTrending();
}
/**
* Re-fetch and render Popular TV carousel with the current TV instance.
* Also refreshes trending since TV statuses depend on the selected instance.
*/
async reloadDiscoverTV() {
const carousel = document.getElementById('popular-tv-carousel');
if (!carousel) return;
carousel.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading TV shows...</p></div>';
try {
let url = './api/requestarr/discover/tv?page=1';
if (this.selectedTVInstance) {
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
url += `&app_type=${encodeURIComponent(decoded.appType || 'sonarr')}&instance_name=${encodeURIComponent(decoded.name || '')}`;
}
const response = await fetch(url);
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularTVResults(carousel, results);
} catch (error) {
console.error('[RequestarrContent] Error reloading discover TV:', error);
}
// Refresh trending with updated instance params (status badges depend on selected instance)
await this.loadTrending();
}
async loadMovieInstances() {
const select = document.getElementById('movies-instance-select');
if (!select) return;
if (this._movieInstancesPopulated) {
this._syncAllMovieSelectors();
return;
}
if (this._loadingMovieInstances) return;
this._loadingMovieInstances = true;
select.innerHTML = '<option value="">Loading instances...</option>';
try {
const dd = await this._fetchBundleDropdownOptions();
const savedValue = this.selectedMovieInstance;
const matched = this._populateSelectFromOptions(select, dd.movie_options, savedValue);
if (matched) {
this._setMovieInstance(matched);
} else {
this.selectedMovieInstance = null;
}
// Setup change handler (remove old listener via clone)
const newSelect = select.cloneNode(true);
if (select.parentNode) {
select.parentNode.replaceChild(newSelect, select);
} else {
const currentSelect = document.getElementById('movies-instance-select');
if (currentSelect && currentSelect.parentNode) {
currentSelect.parentNode.replaceChild(newSelect, currentSelect);
}
}
newSelect.addEventListener('change', async () => {
await this._setMovieInstance(newSelect.value);
const grid = document.getElementById('movies-grid');
if (grid) {
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading movies...</p></div>';
}
if (this.moviesObserver) {
this.moviesObserver.disconnect();
this.moviesObserver = null;
}
this.moviesPage = 1;
this.moviesHasMore = true;
this.isLoadingMovies = false;
this.moviesRequestToken++;
await new Promise(resolve => setTimeout(resolve, 50));
await this.loadMovies();
this.setupMoviesInfiniteScroll();
});
this._movieInstancesPopulated = true;
} catch (error) {
console.error('[RequestarrContent] Error loading movie instances:', error);
select.innerHTML = '<option value="">Error loading instances</option>';
} finally {
this._loadingMovieInstances = false;
}
}
async loadTVInstances() {
const select = document.getElementById('tv-instance-select');
if (!select) return;
if (this._tvInstancesPopulated) {
this._syncAllTVSelectors();
return;
}
if (this._loadingTVInstances) return;
this._loadingTVInstances = true;
select.innerHTML = '<option value="">Loading instances...</option>';
try {
const dd = await this._fetchBundleDropdownOptions();
const savedValue = this.selectedTVInstance;
const matched = this._populateSelectFromOptions(select, dd.tv_options, savedValue);
if (matched) {
this._setTVInstance(matched);
} else {
this.selectedTVInstance = null;
}
// Setup change handler (remove old listener via clone)
const newSelect = select.cloneNode(true);
if (select.parentNode) {
select.parentNode.replaceChild(newSelect, select);
} else {
const currentSelect = document.getElementById('tv-instance-select');
if (currentSelect && currentSelect.parentNode) {
currentSelect.parentNode.replaceChild(newSelect, currentSelect);
}
}
newSelect.addEventListener('change', async () => {
await this._setTVInstance(newSelect.value);
const grid = document.getElementById('tv-grid');
if (grid) {
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading TV shows...</p></div>';
}
if (this.tvObserver) {
this.tvObserver.disconnect();
this.tvObserver = null;
}
this.tvPage = 1;
this.tvHasMore = true;
this.isLoadingTV = false;
this.tvRequestToken++;
await new Promise(resolve => setTimeout(resolve, 50));
await this.loadTV();
this.setupTVInfiniteScroll();
});
this._tvInstancesPopulated = true;
} catch (error) {
console.error('[RequestarrContent] Error loading TV instances:', error);
select.innerHTML = '<option value="">Error loading instances</option>';
} finally {
this._loadingTVInstances = false;
}
}
// ========================================
// CONTENT LOADING
// ========================================
async loadDiscoverContent() {
// Load server defaults + discover instance selectors
await this._loadServerDefaults();
await this.setupDiscoverInstances();
// Load hidden media IDs for filtering
await this.loadHiddenMediaIds();
// Initialize Smart Hunt carousel on the Discover page (check main settings toggle)
this._initDiscoverSmartHunt();
await Promise.all([
this.loadTrending(),
this.loadPopularMovies(),
this.loadPopularTV()
]);
}
/** Initialize Smart Hunt carousel on the Discover page */
async _initDiscoverSmartHunt() {
const section = document.getElementById('discover-smarthunt-section');
if (section) section.style.display = '';
if (!window.SmartHunt) return;
const self = this;
if (this._discoverSmartHunt) {
this._discoverSmartHunt.destroy();
}
this._discoverSmartHunt = new window.SmartHunt({
carouselId: 'discover-smarthunt-carousel',
core: { content: this },
getMovieInstance: () => self.selectedMovieInstance || '',
getTVInstance: () => self.selectedTVInstance || '',
});
this._discoverSmartHunt.load();
}
async loadHiddenMediaIds() {
try {
// Fetch all hidden media (no pagination, we need all IDs)
const [hiddenResp, blacklistResp] = await Promise.all([
fetch('./api/requestarr/hidden-media?page=1&page_size=10000'),
fetch('./api/requestarr/requests/global-blacklist/ids')
]);
const data = await hiddenResp.json();
const hiddenItems = Array.isArray(data.hidden_media)
? data.hidden_media
: (Array.isArray(data.items) ? data.items : []);
// Store hidden media as a Set of "tmdb_id:media_type" for fast cross-instance lookup
this.hiddenMediaSet = new Set();
hiddenItems.forEach(item => {
const key = `${item.tmdb_id}:${item.media_type}`;
this.hiddenMediaSet.add(key);
});
// Store global blacklist as a Set of "tmdb_id:media_type" for fast lookup
this.globalBlacklistSet = new Set();
const blData = await blacklistResp.json();
(blData.items || []).forEach(item => {
this.globalBlacklistSet.add(`${item.tmdb_id}:${item.media_type}`);
});
console.log('[RequestarrContent] Loaded', this.hiddenMediaSet.size, 'hidden media items,', this.globalBlacklistSet.size, 'global blacklist items');
} catch (error) {
console.error('[RequestarrContent] Error loading hidden media IDs:', error);
this.hiddenMediaSet = new Set();
this.globalBlacklistSet = new Set();
}
}
isMediaHidden(tmdbId, mediaType, appType, instanceName) {
if (!this.hiddenMediaSet) return false;
// Cross-instance: check by tmdb_id:media_type only
const key = `${tmdbId}:${mediaType}`;
return this.hiddenMediaSet.has(key);
}
isGloballyBlacklisted(tmdbId, mediaType) {
if (!this.globalBlacklistSet) return false;
return this.globalBlacklistSet.has(`${tmdbId}:${mediaType}`);
}
renderTrendingResults(carousel, results, append) {
if (!carousel) return;
if (results && results.length > 0) {
if (!append) carousel.innerHTML = '';
// Check if user has "hide available" enabled in movie or TV filters
var hideAvailMovie = false, hideAvailTV = false;
try {
var mf = JSON.parse(localStorage.getItem('huntarr_movie_filters') || '{}');
hideAvailMovie = !!mf.hideAvailable;
} catch(e) {}
try {
var tf = JSON.parse(localStorage.getItem('huntarr_tv_filters') || '{}');
hideAvailTV = !!tf.hideAvailable;
} catch(e) {}
results.forEach(item => {
// Hide library items if user has the filter enabled for this media type
if (item.media_type === 'movie' && hideAvailMovie && (item.in_library || item.partial)) return;
if (item.media_type === 'tv' && hideAvailTV && (item.in_library || item.partial)) return;
const suggestedInstance = item.media_type === 'movie' ? (this.selectedMovieInstance || null) : (this.selectedTVInstance || null);
let appType, instanceName;
if (item.media_type === 'movie') {
const decoded = decodeInstanceValue(this.selectedMovieInstance);
appType = decoded.appType;
instanceName = decoded.name;
} else {
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
appType = decoded.appType;
instanceName = decoded.name;
}
const tmdbId = item.tmdb_id || item.id;
if (tmdbId && this.isGloballyBlacklisted(tmdbId, item.media_type)) return;
if (tmdbId && instanceName && this.isMediaHidden(tmdbId, item.media_type, appType, instanceName)) return;
carousel.appendChild(this.createMediaCard(item, suggestedInstance));
});
} else if (!append) {
carousel.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No trending content available</p>';
}
}
/**
* Build the trending API URL with current movie + TV instance params.
* This sends instances directly to the backend so it doesn't need to read from DB.
*/
_buildTrendingUrl() {
let url = './api/requestarr/discover/trending';
const params = [];
if (this.selectedMovieInstance) {
const decoded = decodeInstanceValue(this.selectedMovieInstance);
if (decoded.appType) params.push(`movie_app_type=${encodeURIComponent(decoded.appType)}`);
if (decoded.name) params.push(`movie_instance_name=${encodeURIComponent(decoded.name)}`);
}
if (this.selectedTVInstance) {
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
if (decoded.appType) params.push(`tv_app_type=${encodeURIComponent(decoded.appType)}`);
if (decoded.name) params.push(`tv_instance_name=${encodeURIComponent(decoded.name)}`);
}
if (params.length > 0) url += '?' + params.join('&');
return url;
}
async loadTrending() {
this._trendingPage = 1;
this._trendingHasMore = true;
this._trendingLoading = false;
const carousel = document.getElementById('trending-carousel');
if (!carousel) return;
try {
const baseUrl = this._buildTrendingUrl();
const sep = baseUrl.includes('?') ? '&' : '?';
const url = baseUrl + sep + `page=1&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderTrendingResults(carousel, results, false);
this._trendingHasMore = results.length >= 10;
this._attachCarouselInfiniteScroll(carousel, '_trending');
} catch (error) {
console.error('[RequestarrDiscover] Error loading trending:', error);
carousel.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load trending content</p>';
}
}
async _loadNextTrendingPage() {
if (this._trendingLoading || !this._trendingHasMore) return;
if (this._trendingPage >= 5) { this._trendingHasMore = false; return; }
this._trendingLoading = true;
const carousel = document.getElementById('trending-carousel');
if (!carousel) { this._trendingLoading = false; return; }
try {
const page = this._trendingPage + 1;
const baseUrl = this._buildTrendingUrl();
const sep = baseUrl.includes('?') ? '&' : '?';
const url = baseUrl + sep + `page=${page}&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderTrendingResults(carousel, results, true);
this._trendingPage = page;
this._trendingHasMore = results.length >= 10 && page < 5;
} catch (error) {
console.error('[RequestarrDiscover] Error loading trending page:', error);
} finally {
this._trendingLoading = false;
}
}
renderPopularMoviesResults(carousel, results, append) {
if (!carousel) return;
const decoded = decodeInstanceValue(this.selectedMovieInstance);
if (results && results.length > 0) {
if (!append) carousel.innerHTML = '';
results.forEach(item => {
const tmdbId = item.tmdb_id || item.id;
if (tmdbId && this.isGloballyBlacklisted(tmdbId, 'movie')) return;
if (tmdbId && decoded.name && this.isMediaHidden(tmdbId, 'movie', decoded.appType, decoded.name)) return;
carousel.appendChild(this.createMediaCard(item, this.selectedMovieInstance || null));
});
} else if (!append) {
carousel.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No movies available</p>';
}
}
async loadPopularMovies() {
this._popMoviesPage = 1;
this._popMoviesHasMore = true;
this._popMoviesLoading = false;
const carousel = document.getElementById('popular-movies-carousel');
if (!carousel) return;
try {
const decoded = decodeInstanceValue(this.selectedMovieInstance);
let url = './api/requestarr/discover/movies?page=1';
if (decoded.name) url += `&app_type=${decoded.appType}&instance_name=${encodeURIComponent(decoded.name)}`;
url += `&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularMoviesResults(carousel, results, false);
this._popMoviesHasMore = results.length >= 10;
this._attachCarouselInfiniteScroll(carousel, '_popMovies');
} catch (error) {
console.error('[RequestarrDiscover] Error loading popular movies:', error);
carousel.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load movies</p>';
}
}
async _loadNextPopularMoviesPage() {
if (this._popMoviesLoading || !this._popMoviesHasMore) return;
if (this._popMoviesPage >= 5) { this._popMoviesHasMore = false; return; }
this._popMoviesLoading = true;
const carousel = document.getElementById('popular-movies-carousel');
if (!carousel) { this._popMoviesLoading = false; return; }
try {
const page = this._popMoviesPage + 1;
const decoded = decodeInstanceValue(this.selectedMovieInstance);
let url = `./api/requestarr/discover/movies?page=${page}`;
if (decoded.name) url += `&app_type=${decoded.appType}&instance_name=${encodeURIComponent(decoded.name)}`;
url += `&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularMoviesResults(carousel, results, true);
this._popMoviesPage = page;
this._popMoviesHasMore = results.length >= 10 && page < 5;
} catch (error) {
console.error('[RequestarrDiscover] Error loading popular movies page:', error);
} finally {
this._popMoviesLoading = false;
}
}
renderPopularTVResults(carousel, results, append) {
if (!carousel) return;
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
if (results && results.length > 0) {
if (!append) carousel.innerHTML = '';
results.forEach(item => {
const tmdbId = item.tmdb_id || item.id;
if (tmdbId && this.isGloballyBlacklisted(tmdbId, 'tv')) return;
if (tmdbId && decoded.name && this.isMediaHidden(tmdbId, 'tv', decoded.appType, decoded.name)) return;
carousel.appendChild(this.createMediaCard(item, this.selectedTVInstance || null));
});
} else if (!append) {
carousel.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No TV shows available</p>';
}
}
async loadPopularTV() {
this._popTVPage = 1;
this._popTVHasMore = true;
this._popTVLoading = false;
const carousel = document.getElementById('popular-tv-carousel');
if (!carousel) return;
try {
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
let url = './api/requestarr/discover/tv?page=1';
if (decoded.name) url += `&app_type=${encodeURIComponent(decoded.appType || 'sonarr')}&instance_name=${encodeURIComponent(decoded.name)}`;
url += `&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularTVResults(carousel, results, false);
this._popTVHasMore = results.length >= 10;
this._attachCarouselInfiniteScroll(carousel, '_popTV');
} catch (error) {
console.error('[RequestarrDiscover] Error loading popular TV:', error);
carousel.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load TV shows</p>';
}
}
async _loadNextPopularTVPage() {
if (this._popTVLoading || !this._popTVHasMore) return;
if (this._popTVPage >= 5) { this._popTVHasMore = false; return; }
this._popTVLoading = true;
const carousel = document.getElementById('popular-tv-carousel');
if (!carousel) { this._popTVLoading = false; return; }
try {
const page = this._popTVPage + 1;
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
let url = `./api/requestarr/discover/tv?page=${page}`;
if (decoded.name) url += `&app_type=${encodeURIComponent(decoded.appType || 'sonarr')}&instance_name=${encodeURIComponent(decoded.name)}`;
url += `&_=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
const results = (data.results && data.results.length > 0) ? data.results : [];
this.renderPopularTVResults(carousel, results, true);
this._popTVPage = page;
this._popTVHasMore = results.length >= 10 && page < 5;
} catch (error) {
console.error('[RequestarrDiscover] Error loading popular TV page:', error);
} finally {
this._popTVLoading = false;
}
}
/**
* Attach an infinite scroll listener to a horizontal carousel.
* When the user scrolls within 300px of the right edge, load the next page.
* @param {HTMLElement} carousel - the .media-carousel element
* @param {string} prefix - property prefix, e.g. '_trending', '_popMovies', '_popTV'
*/
_attachCarouselInfiniteScroll(carousel, prefix) {
if (!carousel) return;
// Remove any previous handler for this carousel
const handlerKey = prefix + 'ScrollHandler';
if (this[handlerKey]) {
carousel.removeEventListener('scroll', this[handlerKey]);
}
const self = this;
this[handlerKey] = () => {
const loading = self[prefix + 'Loading'];
const hasMore = self[prefix + 'HasMore'];
if (loading || !hasMore) return;
const remaining = carousel.scrollWidth - carousel.scrollLeft - carousel.clientWidth;
if (remaining < 300) {
if (prefix === '_trending') self._loadNextTrendingPage();
else if (prefix === '_popMovies') self._loadNextPopularMoviesPage();
else if (prefix === '_popTV') self._loadNextPopularTVPage();
}
};
carousel.addEventListener('scroll', this[handlerKey], { passive: true });
}
setupMoviesInfiniteScroll() {
const sentinel = document.getElementById('movies-scroll-sentinel');
if (!sentinel || this.moviesObserver) {
return;
}
this.moviesObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
if (this.moviesHasMore && !this.isLoadingMovies) {
this.loadMoreMovies();
}
});
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0
});
this.moviesObserver.observe(sentinel);
}
async loadMovies(page = 1) {
const grid = document.getElementById('movies-grid');
if (!grid) {
return;
}
if (this.isLoadingMovies && this.selectedMovieInstance === this.activeMovieInstance) {
return;
}
this.isLoadingMovies = true;
const requestToken = ++this.moviesRequestToken;
const requestedInstance = this.selectedMovieInstance;
this.activeMovieInstance = requestedInstance;
// Show loading spinner on first page
if (this.moviesPage === 1) {
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading movies...</p></div>';
}
try {
let url = `./api/requestarr/discover/movies?page=${this.moviesPage}&_=${Date.now()}`;
// Add instance info for library status checking (decode compound value)
if (this.selectedMovieInstance) {
const decoded = decodeInstanceValue(this.selectedMovieInstance);
url += `&app_type=${decoded.appType}&instance_name=${encodeURIComponent(decoded.name)}`;
}
// Add filter parameters
if (this.core.filters) {
const filterParams = this.core.filters.getFilterParams();
if (filterParams) {
url += `&${filterParams}`;
}
}
const response = await fetch(url, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Always clear the grid first to remove loading spinner (even for stale requests)
if (this.moviesPage === 1) {
grid.innerHTML = '';
}
// Check if this request is still valid (not cancelled by a newer request)
if (requestToken !== this.moviesRequestToken || requestedInstance !== this.selectedMovieInstance) {
console.log('[RequestarrContent] Cancelled stale movies request, but spinner already cleared');
return;
}
if (data.results && data.results.length > 0) {
data.results.forEach((item) => {
// Filter out hidden media (decode compound value for correct app_type)
const tmdbId = item.tmdb_id || item.id;
// Filter globally blacklisted items
if (tmdbId && this.isGloballyBlacklisted(tmdbId, 'movie')) return;
if (tmdbId && this.selectedMovieInstance) {
const dHidden = decodeInstanceValue(this.selectedMovieInstance);
if (this.isMediaHidden(tmdbId, 'movie', dHidden.appType, dHidden.name)) {
return; // Skip hidden items
}
}
grid.appendChild(this.createMediaCard(item));
});
// Use has_more from API if available, otherwise check result count
if (data.has_more !== undefined) {
this.moviesHasMore = data.has_more;
} else {
// Fallback to old logic if API doesn't provide has_more
this.moviesHasMore = data.results.length >= 20;
}
} else {
grid.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No movies found</p>';
// Use has_more from API if available
if (data.has_more !== undefined) {
this.moviesHasMore = data.has_more;
} else {
this.moviesHasMore = false;
}
}
} catch (error) {
console.error('[RequestarrContent] Error loading movies:', error);
if (this.moviesPage === 1) {
grid.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load movies</p>';
}
} finally {
this.isLoadingMovies = false;
const sentinel = document.getElementById('movies-scroll-sentinel');
if (sentinel && this.moviesHasMore) {
const rect = sentinel.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (rect.top <= viewportHeight + 200) {
this.loadMoreMovies();
}
}
}
}
loadMoreMovies() {
if (this.moviesHasMore && !this.isLoadingMovies) {
this.moviesPage++;
this.loadMovies(this.moviesPage);
}
}
async loadTV(page = 1) {
const grid = document.getElementById('tv-grid');
if (!grid) {
return;
}
if (this.isLoadingTV && this.selectedTVInstance === this.activeTVInstance) {
return;
}
this.isLoadingTV = true;
const requestToken = ++this.tvRequestToken;
const requestedInstance = this.selectedTVInstance;
this.activeTVInstance = requestedInstance;
// Show loading spinner on first page
if (this.tvPage === 1) {
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading TV shows...</p></div>';
}
try {
let url = `./api/requestarr/discover/tv?page=${this.tvPage}&_=${Date.now()}`;
// Add instance info for library status checking
if (this.selectedTVInstance) {
const decoded = decodeInstanceValue(this.selectedTVInstance, 'sonarr');
url += `&app_type=${encodeURIComponent(decoded.appType || 'sonarr')}&instance_name=${encodeURIComponent(decoded.name || '')}`;
}
// Add filter parameters
if (this.core.tvFilters) {
const filterParams = this.core.tvFilters.getFilterParams();
if (filterParams) {
url += `&${filterParams}`;
}
}
const response = await fetch(url, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Always clear the grid first to remove loading spinner (even for stale requests)
if (this.tvPage === 1) {
grid.innerHTML = '';
}
// Check if this request is still valid (not cancelled by a newer request)
if (requestToken !== this.tvRequestToken || requestedInstance !== this.selectedTVInstance) {
console.log('[RequestarrContent] Cancelled stale TV request, but spinner already cleared');
return;
}
if (data.results && data.results.length > 0) {
const tvDecoded = this.selectedTVInstance ? decodeInstanceValue(this.selectedTVInstance, 'sonarr') : null;
data.results.forEach((item) => {
// Filter out hidden media
const tmdbId = item.tmdb_id || item.id;
// Filter globally blacklisted items
if (tmdbId && this.isGloballyBlacklisted(tmdbId, 'tv')) return;
if (tmdbId && tvDecoded && tvDecoded.name && this.isMediaHidden(tmdbId, 'tv', tvDecoded.appType, tvDecoded.name)) {
return; // Skip hidden items
}
grid.appendChild(this.createMediaCard(item));
});
// Use has_more from API if available, otherwise check result count
if (data.has_more !== undefined) {
this.tvHasMore = data.has_more;
} else {
// Fallback to old logic if API doesn't provide has_more
this.tvHasMore = data.results.length >= 20;
}
} else {
grid.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No TV shows found</p>';
// Use has_more from API if available
if (data.has_more !== undefined) {
this.tvHasMore = data.has_more;
} else {
this.tvHasMore = false;
}
}
} catch (error) {
console.error('[RequestarrContent] Error loading TV shows:', error);
if (this.tvPage === 1) {
grid.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load TV shows</p>';
}
} finally {
this.isLoadingTV = false;
const sentinel = document.getElementById('tv-scroll-sentinel');
if (sentinel && this.tvHasMore) {
const rect = sentinel.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (rect.top <= viewportHeight + 200) {
this.loadMoreTV();
}
}
}
}
setupTVInfiniteScroll() {
const sentinel = document.getElementById('tv-scroll-sentinel');
if (!sentinel || this.tvObserver) {
return;
}
this.tvObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
if (this.tvHasMore && !this.isLoadingTV) {
this.loadMoreTV();
}
});
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0
});
this.tvObserver.observe(sentinel);
}
loadMoreTV() {
if (this.tvHasMore && !this.isLoadingTV) {
this.tvPage++;
this.loadTV(this.tvPage);
}
}
// ========================================
// SMART HUNT GRID
// ========================================
async loadSmartHuntGrid() {
const grid = document.getElementById('smarthunt-grid');
if (!grid) return;
// Populate instance selectors on first load
if (!this._smarthuntInstancesPopulated) {
await this._populateSmartHuntInstances();
this._smarthuntInstancesPopulated = true;
}
// Wire filter button once
this._wireSmartHuntFilters();
if (this.isLoadingSmartHunt) return;
this.isLoadingSmartHunt = true;
// Reset on first page
if (this.smarthuntPage === 0) {
this._smarthuntAllResults = [];
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading Smart Hunt...</p></div>';
}
const requestToken = ++this.smarthuntRequestToken;
const nextPage = this.smarthuntPage + 1;
try {
const results = await this._fetchSmartHuntPage(nextPage);
// Stale check
if (requestToken !== this.smarthuntRequestToken) return;
// Store raw results
this._smarthuntAllResults = (this._smarthuntAllResults || []).concat(results);
this.smarthuntPage = nextPage;
this.smarthuntHasMore = nextPage < 5 && results.length > 0;
// Re-render with filters
this._renderSmartHuntGrid();
} catch (error) {
console.error('[RequestarrContent] Error loading Smart Hunt:', error);
if (this.smarthuntPage === 0) {
grid.innerHTML = '<p style="color: #ef4444; text-align: center; width: 100%; padding: 40px;">Failed to load Smart Hunt results</p>';
}
} finally {
this.isLoadingSmartHunt = false;
// Check if sentinel is already visible and load more
const sentinel = document.getElementById('smarthunt-scroll-sentinel');
if (sentinel && this.smarthuntHasMore) {
const rect = sentinel.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (rect.top <= viewportHeight + 200) {
this.loadMoreSmartHunt();
}
}
}
}
_renderSmartHuntGrid() {
const grid = document.getElementById('smarthunt-grid');
if (!grid) return;
grid.innerHTML = '';
const all = this._smarthuntAllResults || [];
const filtered = this._applySmartHuntFilters(all);
if (filtered.length === 0) {
grid.innerHTML = '<p style="color: #888; text-align: center; width: 100%; padding: 40px;">No results match your filters</p>';
return;
}
filtered.forEach(item => {
const tmdbId = item.tmdb_id || item.id;
if (tmdbId && this.isGloballyBlacklisted(tmdbId, item.media_type || 'movie')) return;
const suggestedInstance = item.media_type === 'tv'
? this.selectedTVInstance
: this.selectedMovieInstance;
grid.appendChild(this.createMediaCard(item, suggestedInstance));
});
}
_applySmartHuntFilters(results) {
const f = this._shFilters || {};
return results.filter(item => {
// Hide library items
if (f.hideAvailable && (item.in_library || item.partial)) return false;
// Media type
if (f.mediaType && f.mediaType !== 'all' && item.media_type !== f.mediaType) return false;
// Year range
const year = item.year || 0;
if (f.yearMin && year < f.yearMin) return false;
if (f.yearMax && year > f.yearMax) return false;
// Rating range
const rating = item.vote_average || 0;
if (f.ratingMin !== undefined && f.ratingMin > 0 && rating < f.ratingMin) return false;
if (f.ratingMax !== undefined && f.ratingMax < 10 && rating > f.ratingMax) return false;
// Vote count range
const votes = item.vote_count || 0;
if (f.votesMin !== undefined && f.votesMin > 0 && votes < f.votesMin) return false;
if (f.votesMax !== undefined && f.votesMax < 10000 && votes > f.votesMax) return false;
return true;
});
}
_wireSmartHuntFilters() {
const filterBtn = document.getElementById('smarthunt-filter-btn');
if (!filterBtn || filterBtn._shWired) return;
filterBtn._shWired = true;
// Initialize filter state
const currentYear = new Date().getFullYear();
const maxYear = currentYear + 3;
this._shFilters = {
hideAvailable: false,
mediaType: 'all',
yearMin: 1900,
yearMax: maxYear,
ratingMin: 0,
ratingMax: 10,
votesMin: 0,
votesMax: 10000
};
this._shMaxYear = maxYear;
// Set dynamic max year on sliders
const yearMinEl = document.getElementById('sh-filter-year-min');
const yearMaxEl = document.getElementById('sh-filter-year-max');
if (yearMinEl) { yearMinEl.max = maxYear; }
if (yearMaxEl) { yearMaxEl.max = maxYear; yearMaxEl.value = maxYear; }
// Open modal on button click
filterBtn.addEventListener('click', () => this._openSmartHuntFilterModal());
// Global close function for onclick handlers in HTML
window._closeSmartHuntFilters = () => this._closeSmartHuntFilterModal();
// Wire all filter inputs for auto-apply
const hideAvail = document.getElementById('sh-hide-available');
if (hideAvail) {
hideAvail.addEventListener('change', () => {
this._shFilters.hideAvailable = hideAvail.checked;
this._shAutoApply();
});
}
const mediaType = document.getElementById('sh-media-type');
if (mediaType) {
mediaType.addEventListener('change', () => {
this._shFilters.mediaType = mediaType.value;
this._shAutoApply();
});
}
// Year sliders
if (yearMinEl && yearMaxEl) {
const updateYear = () => {
let min = parseInt(yearMinEl.value), max = parseInt(yearMaxEl.value);
if (min > max) yearMinEl.value = max;
this._updateShSliderRange('sh-year', yearMinEl, yearMaxEl);
const display = document.getElementById('sh-year-display');
if (display) display.textContent = `From ${yearMinEl.value} to ${yearMaxEl.value}`;
};
yearMinEl.addEventListener('input', updateYear);
yearMaxEl.addEventListener('input', updateYear);
yearMinEl.addEventListener('change', () => { this._shFilters.yearMin = parseInt(yearMinEl.value); this._shAutoApply(); });
yearMaxEl.addEventListener('change', () => { this._shFilters.yearMax = parseInt(yearMaxEl.value); this._shAutoApply(); });
this._updateShSliderRange('sh-year', yearMinEl, yearMaxEl);
}
// Rating sliders
const ratingMinEl = document.getElementById('sh-filter-rating-min');
const ratingMaxEl = document.getElementById('sh-filter-rating-max');
if (ratingMinEl && ratingMaxEl) {
const updateRating = () => {
let min = parseFloat(ratingMinEl.value), max = parseFloat(ratingMaxEl.value);
if (min > max) ratingMinEl.value = max;
this._updateShSliderRange('sh-rating', ratingMinEl, ratingMaxEl);
const display = document.getElementById('sh-rating-display');
if (display) display.textContent = `Ratings between ${parseFloat(ratingMinEl.value).toFixed(1)} and ${parseFloat(ratingMaxEl.value).toFixed(1)}`;
};
ratingMinEl.addEventListener('input', updateRating);
ratingMaxEl.addEventListener('input', updateRating);
ratingMinEl.addEventListener('change', () => { this._shFilters.ratingMin = parseFloat(ratingMinEl.value); this._shAutoApply(); });
ratingMaxEl.addEventListener('change', () => { this._shFilters.ratingMax = parseFloat(ratingMaxEl.value); this._shAutoApply(); });
this._updateShSliderRange('sh-rating', ratingMinEl, ratingMaxEl);
}
// Votes sliders
const votesMinEl = document.getElementById('sh-filter-votes-min');
const votesMaxEl = document.getElementById('sh-filter-votes-max');
if (votesMinEl && votesMaxEl) {
const updateVotes = () => {
let min = parseInt(votesMinEl.value), max = parseInt(votesMaxEl.value);
if (min > max) votesMinEl.value = max;
this._updateShSliderRange('sh-votes', votesMinEl, votesMaxEl);
const display = document.getElementById('sh-votes-display');
if (display) display.textContent = `Number of votes between ${votesMinEl.value} and ${votesMaxEl.value}`;
};
votesMinEl.addEventListener('input', updateVotes);
votesMaxEl.addEventListener('input', updateVotes);
votesMinEl.addEventListener('change', () => { this._shFilters.votesMin = parseInt(votesMinEl.value); this._shAutoApply(); });
votesMaxEl.addEventListener('change', () => { this._shFilters.votesMax = parseInt(votesMaxEl.value); this._shAutoApply(); });
this._updateShSliderRange('sh-votes', votesMinEl, votesMaxEl);
}
}
_updateShSliderRange(prefix, minInput, maxInput) {
const rangeEl = document.getElementById(`${prefix}-range`);
if (!rangeEl) return;
const min = parseFloat(minInput.value);
const max = parseFloat(maxInput.value);
const lo = parseFloat(minInput.min);
const hi = parseFloat(minInput.max);
const pctMin = ((min - lo) / (hi - lo)) * 100;
const pctMax = ((max - lo) / (hi - lo)) * 100;
rangeEl.style.left = pctMin + '%';
rangeEl.style.width = (pctMax - pctMin) + '%';
}
_shAutoApply() {
this._updateShFilterDisplay();
this._renderSmartHuntGrid();
}
_updateShFilterDisplay() {
let count = 0;
const f = this._shFilters || {};
if (f.hideAvailable) count++;
if (f.mediaType && f.mediaType !== 'all') count++;
if (f.yearMin > 1900 || (f.yearMax < this._shMaxYear)) count++;
if (f.ratingMin > 0 || f.ratingMax < 10) count++;
if (f.votesMin > 0 || f.votesMax < 10000) count++;
const text = count === 0 ? '0 Active Filters' : count === 1 ? '1 Active Filter' : `${count} Active Filters`;
const btnCount = document.getElementById('smarthunt-filter-count');
if (btnCount) btnCount.textContent = text;
const modalCount = document.getElementById('sh-filter-active-count');
if (modalCount) modalCount.textContent = text;
}
_openSmartHuntFilterModal() {
const modal = document.getElementById('smarthunt-filter-modal');
if (modal) {
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('show'), 10);
document.body.style.overflow = 'hidden';
}
}
_closeSmartHuntFilterModal() {
const modal = document.getElementById('smarthunt-filter-modal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => {
modal.style.display = 'none';
document.body.style.overflow = '';
}, 150);
}
}
async _fetchSmartHuntPage(page) {
const movieInst = this.selectedMovieInstance || '';
const tvInst = this.selectedTVInstance || '';
let movieAppType = 'radarr', movieName = '';
if (movieInst && movieInst.includes(':')) {
const idx = movieInst.indexOf(':');
movieAppType = movieInst.substring(0, idx);
movieName = movieInst.substring(idx + 1);
} else {
movieName = movieInst;
}
let tvAppType = 'sonarr', tvName = '';
if (tvInst && tvInst.includes(':')) {
const idx = tvInst.indexOf(':');
tvAppType = tvInst.substring(0, idx);
tvName = tvInst.substring(idx + 1);
} else {
tvName = tvInst;
}
const params = new URLSearchParams({
page: String(page),
movie_app_type: movieAppType,
movie_instance_name: movieName,
tv_app_type: tvAppType,
tv_instance_name: tvName,
});
const resp = await fetch(`./api/requestarr/smarthunt?${params.toString()}&_=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.error) throw new Error(data.error);
return data.results || [];
}
async _populateSmartHuntInstances() {
const movieSelect = document.getElementById('smarthunt-movie-instance-select');
const tvSelect = document.getElementById('smarthunt-tv-instance-select');
if (!movieSelect && !tvSelect) return;
try {
const dd = await this._fetchBundleDropdownOptions();
if (movieSelect) {
this._populateSelectFromOptions(movieSelect, dd.movie_options, this.selectedMovieInstance);
if (!movieSelect._shChangeWired) {
movieSelect._shChangeWired = true;
movieSelect.addEventListener('change', async () => {
await this._setMovieInstance(movieSelect.value);
this._reloadSmartHuntGrid();
});
}
}
if (tvSelect) {
this._populateSelectFromOptions(tvSelect, dd.tv_options, this.selectedTVInstance);
if (!tvSelect._shChangeWired) {
tvSelect._shChangeWired = true;
tvSelect.addEventListener('change', async () => {
await this._setTVInstance(tvSelect.value);
this._reloadSmartHuntGrid();
});
}
}
} catch (error) {
console.error('[RequestarrContent] Error populating Smart Hunt instances:', error);
}
}
_reloadSmartHuntGrid() {
this.smarthuntPage = 0;
this.smarthuntHasMore = true;
this.isLoadingSmartHunt = false;
this.smarthuntRequestToken++;
this._smarthuntAllResults = [];
if (this.smarthuntObserver) {
this.smarthuntObserver.disconnect();
this.smarthuntObserver = null;
}
this.loadSmartHuntGrid();
this.setupSmartHuntInfiniteScroll();
}
setupSmartHuntInfiniteScroll() {
const sentinel = document.getElementById('smarthunt-scroll-sentinel');
if (!sentinel || this.smarthuntObserver) return;
this.smarthuntObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
if (this.smarthuntHasMore && !this.isLoadingSmartHunt) {
this.loadMoreSmartHunt();
}
});
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0
});
this.smarthuntObserver.observe(sentinel);
}
loadMoreSmartHunt() {
if (this.smarthuntHasMore && !this.isLoadingSmartHunt) {
this.loadSmartHuntGrid();
}
}
// ========================================
// MEDIA CARD CREATION
// ========================================
createMediaCard(item, suggestedInstance = null) {
const card = document.createElement('div');
card.className = 'media-card';
// Store tmdb_id and media_type as data attributes for easy updates
card.setAttribute('data-tmdb-id', item.tmdb_id);
card.setAttribute('data-media-type', item.media_type);
// Store full item data for hide functionality
card.itemData = item;
// Store suggested instance for modal
card.suggestedInstance = suggestedInstance;
const posterUrl = item.poster_path || './static/images/blackout.jpg';
const year = item.year || 'N/A';
const rating = item.vote_average ? item.vote_average.toFixed(1) : 'N/A';
const overview = item.overview || 'No description available.';
const inLibrary = item.in_library || false;
const partial = item.partial || false;
const importable = item.importable || false;
const pending = item.pending || false;
const hasInstance = item.media_type === 'movie'
? ((this.core.instances.radarr || []).length > 0 || (this.core.instances.movie_hunt || []).length > 0)
: ((this.core.instances.sonarr || []).length > 0 || (this.core.instances.tv_hunt || []).length > 0);
const metaClassName = hasInstance ? 'media-card-meta' : 'media-card-meta no-hide';
// Determine status badge (shared utility)
const statusBadgeHTML = window.MediaUtils ? window.MediaUtils.getStatusBadge(inLibrary, partial, hasInstance, importable, pending) : '';
if (inLibrary || partial) {
card.classList.add('in-library');
}
// Only show Request button when not in library or collection
const showRequestBtn = !inLibrary && !partial;
const overlayActionHTML = showRequestBtn
? '<button class="media-card-request-btn"><i class="fas fa-download"></i> Request</button>'
: '';
const typeBadgeLabel = item.media_type === 'tv' ? 'TV' : 'Movie';
const typeBadgeHTML = `<span class="media-type-badge">${typeBadgeLabel}</span>`;
// Check if globally blacklisted
const isBlacklisted = this.isGloballyBlacklisted(item.tmdb_id, item.media_type);
const blacklistBadgeHTML = isBlacklisted ? '<span class="media-blacklist-badge"><i class="fas fa-ban"></i> Blacklisted</span>' : '';
const blacklistOverlayHTML = isBlacklisted ? '<div class="media-card-blacklist-overlay"><i class="fas fa-ban"></i> Globally Blacklisted</div>' : '';
card.innerHTML = `
<div class="media-card-poster">
${statusBadgeHTML}
<img src="${posterUrl}" alt="${item.title}" onerror="this.src='./static/images/blackout.jpg'">
${typeBadgeHTML}
${blacklistBadgeHTML}
<div class="media-card-overlay">
<div class="media-card-overlay-title">${item.title}</div>
<div class="media-card-overlay-content">
<div class="media-card-overlay-year">${year}</div>
<div class="media-card-overlay-description">${overview}</div>
${isBlacklisted ? blacklistOverlayHTML : overlayActionHTML}
</div>
</div>
</div>
<div class="media-card-info">
<div class="media-card-title" title="${item.title}">${item.title}</div>
<div class="${metaClassName}">
<span class="media-card-year">${year}</span>
<span class="media-card-rating">
<i class="fas fa-star"></i>
${rating}
</span>
${window.MediaUtils ? window.MediaUtils.getActionButton(inLibrary, partial, hasInstance) : ''}
</div>
</div>
`;
// Load and cache image asynchronously after card is created
if (posterUrl && !posterUrl.includes('./static/images/') && window.getCachedTMDBImage && window.tmdbImageCache) {
const imgElement = card.querySelector('.media-card-poster img');
if (imgElement) {
window.getCachedTMDBImage(posterUrl, window.tmdbImageCache).then(cachedUrl => {
if (cachedUrl && cachedUrl !== posterUrl) {
imgElement.src = cachedUrl;
}
}).catch(err => {
console.error('[RequestarrContent] Failed to cache image:', err);
});
}
}
const requestBtn = card.querySelector('.media-card-request-btn');
const hideBtn = card.querySelector('.media-card-hide-btn');
const deleteBtn = card.querySelector('.media-card-delete-btn');
// Click anywhere on card opens detail page (poster/body); Request button opens modal
card.style.cursor = 'pointer';
card.addEventListener('click', (e) => {
// Request button opens modal only
if (requestBtn && (e.target === requestBtn || requestBtn.contains(e.target))) {
e.preventDefault();
e.stopPropagation();
this.core.modal.openModal(item.tmdb_id, item.media_type, card.suggestedInstance);
return;
}
// Delete button opens delete modal
if (deleteBtn && (e.target === deleteBtn || deleteBtn.contains(e.target))) {
e.preventDefault();
e.stopPropagation();
this._openDeleteModal(item, card);
return;
}
// Hide button only hides
if (hideBtn && (e.target === hideBtn || hideBtn.contains(e.target))) {
e.preventDefault();
e.stopPropagation();
this.hideMedia(item.tmdb_id, item.media_type, item.title, card);
return;
}
// Check live card state — badge may have been updated by _syncCardBadge
// after initial render (e.g. modal detected show exists in collection)
const liveInLibrary = card.classList.contains('in-library');
const liveBadge = card.querySelector('.media-card-status-badge');
const livePartial = liveBadge ? liveBadge.classList.contains('partial') : false;
const livePending = liveBadge ? liveBadge.classList.contains('pending') : false;
const shouldOpenModal = !liveInLibrary && !livePartial || livePending;
if (item.media_type === 'movie') {
if (!shouldOpenModal && window.RequestarrDetail && window.RequestarrDetail.openDetail) {
window.RequestarrDetail.openDetail({
tmdb_id: item.tmdb_id, id: item.tmdb_id,
title: item.title, year: item.year,
poster_path: item.poster_path, backdrop_path: item.backdrop_path,
overview: item.overview, vote_average: item.vote_average,
in_library: liveInLibrary
}, { suggestedInstance: card.suggestedInstance });
} else {
this.core.modal.openModal(item.tmdb_id, item.media_type, card.suggestedInstance);
}
} else {
if (!shouldOpenModal && window.RequestarrTVDetail && window.RequestarrTVDetail.openDetail) {
window.RequestarrTVDetail.openDetail({
tmdb_id: item.tmdb_id, id: item.tmdb_id,
title: item.title, name: item.title, year: item.year,
poster_path: item.poster_path, backdrop_path: item.backdrop_path,
overview: item.overview, vote_average: item.vote_average,
in_library: liveInLibrary
}, { suggestedInstance: card.suggestedInstance });
} else {
this.core.modal.openModal(item.tmdb_id, item.media_type, card.suggestedInstance);
}
}
});
return card;
}
/**
* Open the shared delete modal from a Requestarr card.
*/
_openDeleteModal(item, cardElement) {
if (!window.MovieCardDeleteModal) {
console.error('[RequestarrContent] MovieCardDeleteModal not loaded');
return;
}
const inLibrary = item.in_library || false;
const partial = item.partial || false;
const status = inLibrary ? 'available' : (partial ? 'requested' : 'requested');
// Resolve instance info from compound value
let appType = 'movie_hunt';
let instanceName = '';
let instanceId = '';
const compoundValue = this.selectedMovieInstance || (cardElement.suggestedInstance || '');
if (compoundValue) {
const decoded = decodeInstanceValue(compoundValue);
appType = decoded.appType || 'movie_hunt';
instanceName = decoded.name || '';
}
// Try to resolve numeric instance ID
if (this.core && this.core.instances) {
const pool = this.core.instances[appType] || [];
const match = pool.find(i => i.name === instanceName);
if (match) instanceId = match.id || '';
}
window.MovieCardDeleteModal.open(item, {
instanceName: instanceName,
instanceId: instanceId,
status: status,
hasFile: inLibrary,
appType: appType,
onDeleted: function() {
window.MediaUtils.animateCardRemoval(cardElement);
}
});
}
hideMedia(tmdbId, mediaType, title, cardElement) {
const self = this;
const item = cardElement.itemData || {};
const posterPath = item.poster_path || null;
// Resolve app_type and instance name
let appType, instanceName;
if (mediaType === 'movie') {
const compoundValue = self.selectedMovieInstance || (cardElement.suggestedInstance || '');
if (compoundValue) {
const decoded = decodeInstanceValue(compoundValue);
appType = decoded.appType;
instanceName = decoded.name;
} else if (self.core && self.core.instances) {
const mhInst = self.core.instances.movie_hunt || [];
const rInst = self.core.instances.radarr || [];
if (mhInst.length > 0) { appType = 'movie_hunt'; instanceName = mhInst[0].name; }
else if (rInst.length > 0) { appType = 'radarr'; instanceName = rInst[0].name; }
else { appType = 'radarr'; instanceName = null; }
} else {
appType = 'radarr'; instanceName = null;
}
} else {
appType = 'sonarr';
instanceName = self.selectedTVInstance;
if (!instanceName && cardElement.suggestedInstance) instanceName = cardElement.suggestedInstance;
if (!instanceName && self.core && self.core.instances) {
const instances = self.core.instances.sonarr || [];
instanceName = instances.length > 0 ? instances[0].name : null;
}
}
window.MediaUtils.hideMedia({
tmdbId: tmdbId,
mediaType: mediaType,
title: title,
posterPath: posterPath,
appType: appType || 'radarr',
instanceName: instanceName || '',
cardElement: cardElement,
hiddenMediaSet: self.hiddenMediaSet
});
}
}