/** * 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; // 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']; 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']; 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 = ''; 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 = '
Loading movies...
Loading TV shows...
Loading movies...
Loading TV shows...
No trending content available
'; } } /** * 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 = 'Failed to load trending content
'; } } 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 = 'No movies available
'; } } 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 = 'Failed to load movies
'; } } 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 = 'No TV shows available
'; } } 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 = 'Failed to load TV shows
'; } } 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 = 'Loading movies...
No movies found
'; // 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 = 'Failed to load movies
'; } } 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 = 'Loading TV shows...
No TV shows found
'; // 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 = 'Failed to load TV shows
'; } } 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); } } // ======================================== // 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 ? '' : ''; const typeBadgeLabel = item.media_type === 'tv' ? 'TV' : 'Movie'; const typeBadgeHTML = `${typeBadgeLabel}`; // Check if globally blacklisted const isBlacklisted = this.isGloballyBlacklisted(item.tmdb_id, item.media_type); const blacklistBadgeHTML = isBlacklisted ? ' Blacklisted' : ''; const blacklistOverlayHTML = isBlacklisted ? '' : ''; card.innerHTML = `