// Jellyfin Watchlist Script // Adds watchlist functionality throughout Jellyfin interface // Requires: cardBuilder.js, localStorageCache.js, modal.js, utils.js, statistics.js modules to be loaded before this script // Chart.js is loaded as a thirdParty dependency of watchlist // Requirement #2: Custom Tabs plugin /* In the Custom Tabs plugin, add a new tab with the following HTML content:
*/ (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks Watchlist]', ...args); const WARN = (...args) => console.warn('[KefinTweaks Watchlist]', ...args); const ERR = (...args) => console.error('[KefinTweaks Watchlist]', ...args); LOG('Initializing...'); // Initialize localStorage cache manager const localStorageCache = new window.LocalStorageCache(); const WATCHLIST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes // Playback monitoring for watchlist cleanup let playbackMonitorInitialized = false; // Store watchlist tab index let _watchlistTabIndex = null; // Data optimization functions for localStorage storage function optimizeProgressDataForStorage(progressDataArray) { return progressDataArray.map(progress => { const episodeSource = progress.episodes || progress.playEvents || []; const playEvents = episodeSource .filter((ep) => { if (ep.UserData?.Played && ep.UserData?.LastPlayedDate) return true; // Restored compact playEvents already have LastPlayedDate at top level return !!(ep.LastPlayedDate && (ep.Id || ep.Name)); }) .map((ep) => ({ Id: ep.Id, Name: ep.Name, LastPlayedDate: ep.UserData?.LastPlayedDate || ep.LastPlayedDate, RunTimeTicks: ep.RunTimeTicks || 0, ParentIndexNumber: ep.ParentIndexNumber != null ? ep.ParentIndexNumber : null, IndexNumber: ep.IndexNumber != null ? ep.IndexNumber : null })); const seriesUserData = progress.series?.UserData ? { IsFavorite: !!progress.series.UserData.IsFavorite, LastPlayedDate: progress.series.UserData.LastPlayedDate || null, PlayCount: progress.series.UserData.PlayCount || 0, Played: !!progress.series.UserData.Played } : undefined; return { series: { Id: progress.series.Id, Name: progress.series.Name, ImageTags: progress.series.ImageTags, Status: progress.series.Status, ProductionYear: progress.series.ProductionYear, ...(seriesUserData ? { UserData: seriesUserData } : {}) }, watchedCount: progress.watchedCount, totalEpisodes: progress.totalEpisodes, remainingCount: progress.remainingCount, percentage: progress.percentage, totalRuntime: progress.totalRuntime, watchedRuntime: progress.watchedRuntime, remainingRuntime: progress.remainingRuntime, lastWatchedEpisode: progress.lastWatchedEpisode ? { Id: progress.lastWatchedEpisode.Id, Name: progress.lastWatchedEpisode.Name, IndexNumber: progress.lastWatchedEpisode.IndexNumber, ParentIndexNumber: progress.lastWatchedEpisode.ParentIndexNumber, UserData: progress.lastWatchedEpisode.UserData, ImageTags: progress.lastWatchedEpisode.ImageTags } : null, binaryProgress: progress.binaryProgress || {}, playEvents }; }); } function optimizeMovieDataForStorage(movieDataArray) { // Return optimized movie data return movieDataArray.map(movie => { const optimizedData = { Id: movie.Id, Name: movie.Name, BackdropImageTags: movie.BackdropImageTags, ImageTags: movie.ImageTags, PremiereDate: movie.PremiereDate, RunTimeTicks: movie.RunTimeTicks, ProductionYear: movie.ProductionYear } if (movie.UserData) { optimizedData.UserData = { IsFavorite: movie.UserData.IsFavorite, LastPlayedDate: movie.UserData.LastPlayedDate, PlayCount: movie.UserData.PlayCount, Played: movie.UserData.Played } } return optimizedData; }); } // Optimize watchlist data for localStorage storage function optimizeWatchlistDataForStorage(watchlistDataArray) { return watchlistDataArray.map(item => { const optimizedData = { Id: item.Id, Type: item.Type, MediaType: item.MediaType, Name: item.Name, SeriesId: item.SeriesId, SeriesName: item.SeriesName, SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, ParentBackdropItemId: item.ParentBackdropItemId, ParentBackdropImageTags: item.ParentBackdropImageTags, ParentThumbImageTag: item.ParentPrimaryImageTags, IndexNumber: item.IndexNumber, ParentIndexNumber: item.ParentIndexNumber, ImageTags: item.ImageTags, BackdropImageTags: item.BackdropImageTags, PremiereDate: item.PremiereDate, ProductionYear: item.ProductionYear }; if (item.UserData) { optimizedData.UserData = { IsFavorite: item.UserData.IsFavorite, Played: item.UserData.Played, LastPlayedDate: item.UserData.LastPlayedDate, PlayCount: item.UserData.PlayCount } } return optimizedData; }); } // Add refresh button to tab headers function addRefreshButtons() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; // Progress tab refresh button and sort button const progressStats = watchlistSection.querySelector('.progress-header-stats-container'); if (progressStats) { const refreshBtn = createRefreshButton('progress', 'Series Progress'); // Add sort button next to refresh button const sortBtn = createSortButton(); progressStats.appendChild(sortBtn); progressStats.appendChild(refreshBtn); } // History tab refresh button const historyStats = watchlistSection.querySelector('.movie-history-stats-container'); if (historyStats) { // Add sort button first const sortBtn = createMovieSortButton(); historyStats.appendChild(sortBtn); const refreshBtn = createRefreshButton('movies', 'Movie History'); historyStats.appendChild(refreshBtn); } // Watchlist tab refresh button const watchlistHeaderRight = watchlistSection.querySelector('.watchlist-header-right'); if (watchlistHeaderRight) { const importExportBtn = createImportExportButton(); watchlistHeaderRight.appendChild(importExportBtn); const refreshBtn = createRefreshButton('watchlist', 'Watchlist'); watchlistHeaderRight.appendChild(refreshBtn); } } // Create a refresh button element function createRefreshButton(cacheName, label) { const refreshBtn = document.createElement('button'); refreshBtn.className = 'paper-icon-button-light emby-button '; refreshBtn.innerHTML = ''; refreshBtn.title = `Refresh ${label} data from server`; refreshBtn.onclick = async () => { refreshBtn.disabled = true; refreshBtn.innerHTML = ''; try { // Clear localStorage cache if (cacheName === 'progress') { localStorageCache.clear(`progress_v2`); localStorageCache.clear(`progress`); // legacy key // Clear in-memory cache progressCache.data = []; progressCache.allDataLoaded = false; progressCache.totalPages = 0; tabStates.progress.isDataFetched = false; tabStates.statistics.isDataFetched = false; if (window.KefinWatchlistStats?.destroy) { window.KefinWatchlistStats.destroy(); } } else if (cacheName === 'movies') { localStorageCache.clear(`movies`); // Clear in-memory cache movieCache.data = []; movieCache.allDataLoaded = false; movieCache.totalPages = 0; tabStates.history.isDataFetched = false; tabStates.statistics.isDataFetched = false; if (window.KefinWatchlistStats?.destroy) { window.KefinWatchlistStats.destroy(); } } else if (cacheName === 'watchlist') { await invalidateAllWatchlistQueries(); resetWatchlistItemsByType(); } // Re-fetch data if (cacheName === 'progress') { await initProgressTab(); } else if (cacheName === 'movies') { await initHistoryTab(); } else if (cacheName === 'watchlist') { await initWatchlistTab(true); } // Show success feedback with checkmark refreshBtn.innerHTML = ''; setTimeout(() => { refreshBtn.innerHTML = ''; refreshBtn.disabled = false; }, 2000); } catch (error) { ERR('Failed to refresh cache:', error); refreshBtn.innerHTML = ''; setTimeout(() => { refreshBtn.innerHTML = ''; refreshBtn.disabled = false; }, 3000); } }; return refreshBtn; } // Create import/export button element function createImportExportButton() { const btn = document.createElement('button'); btn.className = 'layout-toggle-btn'; btn.innerHTML = ''; btn.title = 'Import/Export Watchlist'; btn.onclick = () => { showImportExportModal(); }; return btn; } // Create sort button element function createSortButton() { const sortBtn = document.createElement('button'); sortBtn.className = 'sort-button'; sortBtn.id = 'progress-sort-btn'; // Get current sort order const currentSort = getCurrentSortOrder(); const currentLabel = SORT_OPTIONS[currentSort]?.label || 'Last Watched'; sortBtn.innerHTML = ` ${currentLabel} `; sortBtn.title = 'Sort series by different criteria'; sortBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); showSortModal(); }; // Update the label with direction indicator after creation updateSortButtonLabel(currentSort); return sortBtn; } // Create movie sort button element function createMovieSortButton() { const sortBtn = document.createElement('button'); sortBtn.className = 'sort-button'; sortBtn.id = 'movie-sort-btn'; // Get current sort order const currentSort = getCurrentMovieSortOrder(); const currentLabel = MOVIE_SORT_OPTIONS[currentSort]?.label || 'Last Watched'; sortBtn.innerHTML = ` ${currentLabel} `; sortBtn.title = 'Sort movies by different criteria'; sortBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); showMovieSortModal(); }; // Update the label with direction indicator after creation updateMovieSortButtonLabel(currentSort); return sortBtn; } // Show sort options modal function showSortModal() { // Create modal content const sortOptionsContent = `

Sort By

${Object.entries(SORT_OPTIONS).map(([key, option]) => ` `).join('')}

Sort Order

`; // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'sort-modal', content: sortOptionsContent, onOpen: (modalInstance) => { // Set current selection const currentSort = getCurrentSortOrder(); const currentDirection = getCurrentSortDirection(); const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`); const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`); if (currentRadio) { currentRadio.checked = true; } if (currentDirectionRadio) { currentDirectionRadio.checked = true; } // Add event listeners for radio button changes const sortOptions = modalInstance.dialog.querySelectorAll('input[name="sortOption"]'); const sortDirections = modalInstance.dialog.querySelectorAll('input[name="sortDirection"]'); // Apply changes immediately when radio buttons change const applyChanges = () => { const selectedOption = modalInstance.dialog.querySelector('input[name="sortOption"]:checked'); const selectedDirection = modalInstance.dialog.querySelector('input[name="sortDirection"]:checked'); if (selectedOption) { const direction = selectedDirection ? selectedDirection.value : 'desc'; setSortOrder(selectedOption.value, direction); updateSortButtonLabel(selectedOption.value, direction); } }; sortOptions.forEach(option => { option.addEventListener('change', applyChanges); }); sortDirections.forEach(direction => { direction.addEventListener('change', applyChanges); }); } }); } // Update sort button label function updateSortButtonLabel(sortKey, direction = null) { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; const sortBtn = watchlistSection.querySelector('#progress-sort-btn'); if (sortBtn) { const currentDirection = direction || getCurrentSortDirection(); const directionText = currentDirection === 'asc' ? '↑' : '↓'; const label = SORT_OPTIONS[sortKey]?.label || 'Last Watched'; const labelSpan = sortBtn.querySelector('.sort-label'); if (labelSpan) { labelSpan.textContent = `${label} ${directionText}`; } } } // Show movie sort options modal function showMovieSortModal() { // Create modal content const movieSortOptionsContent = `

Sort By

${Object.entries(MOVIE_SORT_OPTIONS).map(([key, option]) => ` `).join('')}

Sort Order

`; // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'movie-sort-modal', content: movieSortOptionsContent, onOpen: (modalInstance) => { // Set current selection const currentSort = getCurrentMovieSortOrder(); const currentDirection = getCurrentMovieSortDirection(); const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`); const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`); if (currentRadio) { currentRadio.checked = true; } if (currentDirectionRadio) { currentDirectionRadio.checked = true; } // Add event listeners for radio button changes const sortOptions = modalInstance.dialog.querySelectorAll('input[name="movieSortOption"]'); const sortDirections = modalInstance.dialog.querySelectorAll('input[name="movieSortDirection"]'); // Apply changes immediately when radio buttons change const applyChanges = () => { const selectedOption = modalInstance.dialog.querySelector('input[name="movieSortOption"]:checked'); const selectedDirection = modalInstance.dialog.querySelector('input[name="movieSortDirection"]:checked'); if (selectedOption) { const direction = selectedDirection ? selectedDirection.value : 'desc'; setMovieSortOrder(selectedOption.value, direction); updateMovieSortButtonLabel(selectedOption.value, direction); renderHistoryContent(); } }; sortOptions.forEach(option => { option.addEventListener('change', applyChanges); }); sortDirections.forEach(direction => { direction.addEventListener('change', applyChanges); }); } }); } // Update movie sort button label function updateMovieSortButtonLabel(sortKey, direction = null) { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; const sortBtn = watchlistSection.querySelector('#movie-sort-btn'); if (sortBtn) { const currentDirection = direction || getCurrentMovieSortDirection(); const directionText = currentDirection === 'asc' ? '↑' : '↓'; const label = MOVIE_SORT_OPTIONS[sortKey]?.label || 'Last Watched'; const labelSpan = sortBtn.querySelector('.sort-label'); if (labelSpan) { labelSpan.textContent = `${label} ${directionText}`; } } } // Function to update the last watched display function updateLastWatched(lastWatchedElement, episodeInfo) { if (!lastWatchedElement || !episodeInfo) return; const timeAgo = formatTimeAgo(new Date().toISOString()); const formattedDate = formatLastWatchedDate(new Date().toISOString()); const episodeInfoText = `${episodeInfo.season}x${episodeInfo.episode.toString().padStart(2, '0')} "${episodeInfo.title}"`; lastWatchedElement.innerHTML = `Last watched ${episodeInfoText} ${timeAgo} on ${formattedDate}.`; } // Function to update last watched display after removing an episode from watched async function updateLastWatchedAfterRemoval(seriesId) { const cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress || !cachedProgress.episodes) { return; } const progressCard = document.querySelector(`[data-series-id="${seriesId}"]`)?.closest('.progress-card'); if (!progressCard) return; const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); if (!lastWatchedElement) return; // Find the most recently watched episode from the cache const watchedEpisodes = cachedProgress.episodes.filter(ep => ep.UserData && ep.UserData.Played === true && ep.UserData.LastPlayedDate ); if (watchedEpisodes.length > 0) { // Sort by LastPlayedDate and get the most recent const lastWatched = watchedEpisodes.sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate) )[0]; const episodeInfo = { title: lastWatched.Name, season: lastWatched.ParentIndexNumber || 0, episode: lastWatched.IndexNumber || 0 }; updateLastWatched(lastWatchedElement, episodeInfo); } else { // No more watched episodes, clear the display lastWatchedElement.innerHTML = ''; } } // Helper function to fetch episode by series ID, season, and episode number async function fetchEpisode(seriesId, season, episode) { if (!seriesId || !season || !episode) { throw new Error('Missing required information: seriesId, season, or episode'); } LOG(`Fetching episode ID: series ${seriesId}, season ${season}, episode ${episode}`); const episodes = await fetchEpisodesForSeries(seriesId); if (!episodes || episodes.length === 0) { throw new Error(`No episodes found for series ${seriesId}`); } // Find the episode that matches the season and episode number const matchingEpisode = episodes.find(ep => { const start = ep.IndexNumber; const end = ep.IndexNumberEnd || ep.IndexNumber; return ep.ParentIndexNumber === parseInt(season) && parseInt(episode) >= start && parseInt(episode) <= end; }); if (matchingEpisode) { LOG(`Found episode ID: ${matchingEpisode.Id} for season ${season}, episode ${episode}`); return matchingEpisode; } else { throw new Error(`Could not find episode for series ${seriesId}, season ${season}, episode ${episode}`); } } // Show episode watched confirmation modal function showEpisodeWatchedConfirmation(episodeId, element, episodeInfo = null, seriesId = null, isWatched = false) { // Note: If episodeId is missing, it will be fetched when the confirm button is clicked // using the seriesId, season, and episode information from episodeInfo // Create modal content based on watched status const episodeTitle = episodeInfo ? episodeInfo.title : 'Episode Title'; const episodeDetails = episodeInfo ? `Season ${episodeInfo.season}, Episode ${episodeInfo.episode}` : 'Season X, Episode Y'; const message = isWatched ? 'Are you sure you want to remove this episode from watched?' : 'Are you sure you want to mark this episode as watched?'; const confirmButtonText = isWatched ? 'Remove from Watched' : 'Mark as Watched'; const confirmButtonIcon = isWatched ? 'remove_circle' : 'check'; const confirmationContent = `
${episodeTitle}
${episodeDetails}
${message}
`; // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'episode-confirmation-modal', title: isWatched ? 'Remove Episode from Watched' : 'Mark Episode as Watched', content: confirmationContent, onOpen: (modalInstance) => { // Store the episode ID, element, and watched status for the confirmation modalInstance.dialog.dataset.episodeId = episodeId || ''; modalInstance.dialog.dataset.elementType = element.classList.contains('progress-chunk') ? 'chunk' : 'button'; modalInstance.dialog.dataset.seriesId = (episodeInfo && episodeInfo.seriesId) || seriesId || ''; modalInstance.dialog.dataset.isWatched = isWatched; // Store season/episode for binary mode if (episodeInfo) { modalInstance.dialog.dataset.season = episodeInfo.season || ''; modalInstance.dialog.dataset.episode = episodeInfo.episode || ''; } // Add event listeners const confirmBtn = modalInstance.dialog.querySelector('#confirm-episode-action'); const cancelBtn = modalInstance.dialog.querySelector('#cancel-episode-action'); const viewEpisodeBtn = modalInstance.dialog.querySelector('#view-episode-btn'); viewEpisodeBtn.onclick = async () => { // Navigate to the episode's item page let episodeId = modalInstance.dialog.dataset.episodeId; const seriesId = modalInstance.dialog.dataset.seriesId; const season = modalInstance.dialog.dataset.season; const episode = modalInstance.dialog.dataset.episode; // If episodeId is missing, fetch it using seriesId, season, and episode if (!episodeId) { try { const item = await fetchEpisode(seriesId, season, episode); if (item) { episodeId = item.Id; modalInstance.dialog.dataset.episodeId = episodeId; } } catch (err) { WARN('Cannot navigate to episode:', err.message); window.ModalSystem.close('episode-confirmation-modal'); return; } } if (episodeId) { Dashboard.navigate(`#/details?id=${episodeId}`); } window.ModalSystem.close('episode-confirmation-modal'); }; confirmBtn.onclick = async () => { let episodeId = modalInstance.dialog.dataset.episodeId; const elementType = modalInstance.dialog.dataset.elementType; const seriesId = modalInstance.dialog.dataset.seriesId; const season = modalInstance.dialog.dataset.season; const episode = modalInstance.dialog.dataset.episode; const isCurrentlyWatched = modalInstance.dialog.dataset.isWatched === 'true'; // If episodeId is missing, fetch it using seriesId, season, and episode if (!episodeId) { try { const item = await fetchEpisode(seriesId, season, episode); if (item) { episodeId = item.Id; modalInstance.dialog.dataset.episodeId = episodeId; } } catch (err) { ERR(err.message); window.ModalSystem.close('episode-confirmation-modal'); return; } } // Find the original element let element = null; if (elementType === 'chunk') { // Find progress bar by series ID, then find chunk by season/episode const progressBar = document.querySelector(`.progress-bar[data-series-id="${seriesId}"]`); if (progressBar) { const season = modalInstance.dialog.dataset.season; const episode = modalInstance.dialog.dataset.episode; element = progressBar.querySelector(`[data-season="${season}"][data-episode="${episode}"]`); } } if (!element) { element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`); } // Reconstruct episodeInfo for updateLastWatched if needed const episodeInfoForUpdate = episodeInfo || { title: modalInstance.dialog.querySelector('.episode-title')?.textContent || 'Episode', season: parseInt(modalInstance.dialog.dataset.season), episode: parseInt(modalInstance.dialog.dataset.episode), seriesId: seriesId }; if (isCurrentlyWatched) { // Remove from watched await toggleEpisodeWatchedStatus(episodeId, element, false); // Update last watched display await updateLastWatchedAfterRemoval(seriesId); } else { // Mark as watched await toggleEpisodeWatchedStatus(episodeId, element); // Update the .progress-last-watched element with the newly watched episode const progressCard = document.querySelector(`[data-series-id="${seriesId}"]`)?.closest('.progress-card'); if (progressCard) { const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); updateLastWatched(lastWatchedElement, episodeInfoForUpdate); } } window.ModalSystem.close('episode-confirmation-modal'); }; cancelBtn.onclick = () => { window.ModalSystem.close('episode-confirmation-modal'); }; } }); } // Function to update the unwatched episodes list, removing watched episodes async function updateUnwatchedEpisodesList(seriesId) { const progressCard = document.querySelector(`[data-series-id="${seriesId}"].progress-card`); if (!progressCard) return; const unwatchedEpisodesList = progressCard.querySelector('.unwatched-episodes-list'); if (!unwatchedEpisodesList) return; // Check if there are any episode items rendered const episodeItems = unwatchedEpisodesList.querySelectorAll('.unwatched-episode-item'); if (episodeItems.length === 0) return; // Get the updated cache to check which episodes are now watched const cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress || !cachedProgress.episodes) return; // Create a set of watched episode identifiers (season + episode number) const watchedEpisodes = new Set(); cachedProgress.episodes.forEach(ep => { if (ep.UserData && ep.UserData.Played === true) { // For multi-part episodes, mark all parts as watched const expanded = expandMultiPartEpisodes(ep); expanded.forEach(expandedEp => { watchedEpisodes.add(`${expandedEp.ParentIndexNumber}_${expandedEp.expandedIndex}`); }); } }); // Remove any episode items that are now watched episodeItems.forEach(item => { const season = item.getAttribute('data-season'); const episode = item.getAttribute('data-episode'); const key = `${season}_${episode}`; if (watchedEpisodes.has(key)) { // Fade out and remove the item item.style.opacity = '0.5'; item.style.transition = 'opacity 0.3s ease'; setTimeout(() => { item.remove(); // Check if no more episodes remain const remainingEpisodes = unwatchedEpisodesList.querySelectorAll('.unwatched-episode-item'); if (remainingEpisodes.length === 0) { unwatchedEpisodesList.innerHTML = '
No unwatched episodes found
'; } }, 300); } }); } // Show mark all episodes confirmation modal function showMarkAllEpisodesConfirmation(seriesId, buttonElement, seriesInfo) { // Create modal content const confirmationContent = `
${seriesInfo.name}
${seriesInfo.watchedCount} of ${seriesInfo.totalEpisodes} episodes watched (${seriesInfo.percentage}%)
${seriesInfo.unwatchedCount} unwatched episodes will be marked as watched
Are you sure you want to mark all remaining episodes as watched?
`; // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'mark-all-confirmation-modal', title: 'Mark All Episodes as Watched', content: confirmationContent, onOpen: (modalInstance) => { // Store the series ID and button for the confirmation modalInstance.dialog.dataset.seriesId = seriesId; modalInstance.dialog.dataset.buttonElement = 'true'; // Add event listeners const confirmBtn = modalInstance.dialog.querySelector('#confirm-mark-all-watched'); const cancelBtn = modalInstance.dialog.querySelector('#cancel-mark-all-watched'); confirmBtn.onclick = async () => { const seriesId = modalInstance.dialog.dataset.seriesId; // Find the mark all button const markAllBtn = document.querySelector(`[data-series-id="${seriesId}"].mark-all-watched-btn`); if (confirmBtn) { await markAllEpisodesAsWatched(seriesId, confirmBtn); } window.ModalSystem.close('mark-all-confirmation-modal'); }; cancelBtn.onclick = () => { window.ModalSystem.close('mark-all-confirmation-modal'); }; } }); } /** Sum played-episode RunTimeTicks for a progress row (watchedRuntime is seconds when set). */ function getSeriesWatchedRuntimeTicks(progress) { if (!progress) return 0; const source = Array.isArray(progress.episodes) && progress.episodes.length ? progress.episodes : (progress.playEvents || []); if (source.length) { let totalTicks = 0; source.forEach((ep) => { const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate; const played = ep?.UserData ? ep.UserData.Played === true : !!key; if (!played) return; totalTicks += Number(ep.RunTimeTicks) || 0; }); return totalTicks; } if (typeof progress.watchedRuntime === 'number' && progress.watchedRuntime > 0) { return Math.round(progress.watchedRuntime * 10000000); } return 0; } function formatWatchedRuntimeLabel(ticks) { const seconds = (Number(ticks) || 0) / 10000000; const hours = seconds / 3600; if (hours >= 1) return `${hours.toFixed(1)} hours`; const mins = Math.round(seconds / 60); return `${mins} min`; } function escapeHtmlWatchlist(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** Map progress rows to top-show entries sorted by total watch time. */ function mapProgressToTopShows(progressData, limit = null) { const mapped = (progressData || []) .map((progress) => ({ name: progress.series?.Name || 'Unknown', episodesWatched: progress.watchedCount || 0, totalEpisodes: progress.totalEpisodes || 0, percentage: progress.percentage || 0, seriesId: progress.series?.Id, watchedRuntimeTicks: getSeriesWatchedRuntimeTicks(progress) })) .sort((a, b) => { const tickDiff = b.watchedRuntimeTicks - a.watchedRuntimeTicks; if (tickDiff !== 0) return tickDiff; return (b.episodesWatched || 0) - (a.episodesWatched || 0); }); return limit != null ? mapped.slice(0, limit) : mapped; } function bindShowAllShowsButton(root) { const scope = root && root.querySelector ? root : document; const showAllBtn = scope.querySelector?.('#show-all-shows-btn') || getElementByIdSafe('show-all-shows-btn'); if (!showAllBtn || showAllBtn.dataset.showAllBound === 'true') return; showAllBtn.dataset.showAllBound = 'true'; showAllBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showAllShowsModal(); }); } // Show all shows modal function showAllShowsModal() { if (!window.ModalSystem) { WARN('ModalSystem not available'); return; } // Get all progress data and sort by total watch time (same as Top Shows) const progressData = progressCache.data || []; const allShows = mapProgressToTopShows(progressData); let showsHtml = ''; if (allShows.length === 0) { showsHtml = '
No shows watched yet
'; } else { showsHtml = allShows.map((show, index) => { const rank = index + 1; const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes'; return `
${rank}
${escapeHtmlWatchlist(show.name)}
${formatWatchedRuntimeLabel(show.watchedRuntimeTicks)} · ${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
`; }).join(''); } // Create modal content const modalContent = `
${showsHtml}
`; // Create modal using the generic modal system window.ModalSystem.create({ id: 'all-shows-modal', title: 'All Watched Shows', content: modalContent, onOpen: (modalInstance) => { // Modal is ready and content is populated LOG('All shows modal opened'); } }); } // Centralized selector function for visible library / custom page function getVisibleLibraryPage() { return document.querySelector('.libraryPage:not(.hide), .customPage:not(.hide), .homePage:not(.hide)'); } // Safe element getter that targets the visible library page function getElementByIdSafe(elementId) { const libraryPage = getVisibleLibraryPage(); if (libraryPage) { const scoped = libraryPage.querySelector(`#${CSS.escape ? CSS.escape(elementId) : elementId}`); if (scoped) return scoped; } return document.getElementById(elementId); } // Tab state tracking to avoid unnecessary re-rendering const tabStates = { progress: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false, isRendering: false }, history: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }, watchlist: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }, statistics: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false } }; // Check if we should skip rendering based on current state function shouldSkipRendering(tabName, currentPage, currentSearch, container, data) { const state = tabStates[tabName]; const isSamePage = state.currentPage === currentPage; const isSameSearch = state.currentSearch === currentSearch; // For progress tab, also check if sort order or direction has changed let isSameSort = true; if (tabName === 'progress') { const currentSort = getCurrentSortOrder(); const currentDirection = getCurrentSortDirection(); isSameSort = state.currentSort === currentSort && state.currentDirection === currentDirection; } else if (tabName === 'history') { // For history tab, check if movie sort order or direction has changed const currentSort = getCurrentMovieSortOrder(); const currentDirection = getCurrentMovieSortDirection(); isSameSort = state.currentSort === currentSort && state.currentDirection === currentDirection; } const children = container.querySelectorAll(':scope > *:not(.loading-message)'); // Check if container has the right number of children // TODO - Improve this to check if the children are the same as the data const hasCorrectContent = container && children.length === data.length; // Skip if we have content and it's the same page/search/sort/data count return state.hasContent && isSamePage && isSameSearch && isSameSort && hasCorrectContent; } // Update tab state after successful render function updateTabState(tabName, currentPage, currentSearch) { const state = { currentPage, currentSearch, hasContent: true, lastDataHash: '', // No longer used isFetching: false, isDataFetched: true }; // For progress tab, also track current sort order and direction if (tabName === 'progress') { state.currentSort = getCurrentSortOrder(); state.currentDirection = getCurrentSortDirection(); } else if (tabName === 'history') { // For history tab, track current movie sort order and direction state.currentSort = getCurrentMovieSortOrder(); state.currentDirection = getCurrentMovieSortDirection(); } tabStates[tabName] = state; } // Clear tab state when content is cleared function clearTabState(tabName) { tabStates[tabName].hasContent = false; tabStates[tabName].lastDataHash = ''; tabStates[tabName].isDataFetched = false; tabStates[tabName].isFetching = false; } function getWatchlistSection() { const visibleWatchlistSections = document.querySelectorAll('.libraryPage:not(.hide) .sections.watchlist, div[id^="customTab_"] .sections.watchlist'); if (!visibleWatchlistSections || visibleWatchlistSections.length === 0) { return null; } if (visibleWatchlistSections.length > 1) { return visibleWatchlistSections[visibleWatchlistSections.length - 1]; } return visibleWatchlistSections[0]; } // Add custom CSS for watchlist icon const style = document.createElement('style'); style.textContent = ` .material-icons.watchlist:before { content: "\\e866"; } .loading-message { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; color: #888; grid-column: 1 / -1; } .loading-spinner { width: 32px; height: 32px; border: 3px solid #f3f3f3; border-top: 3px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); LOG('Custom watchlist icon CSS added'); /************ Progress Tab Functions ************/ // Generic cache factory function function createGenericCache(pageSize = 20) { return { data: [], // Main data array filteredData: [], // Filtered data array searchTerm: '', // Current search term filteredPages: 0, // Pages for filtered data totalPages: 0, // Pages for all data pageSize, // Items per page allDataLoaded: false, // Data loading state sorted: false, // Data sorting state currentPage: 1 // Current page for this tab }; } // Progress caching and pagination configuration const progressCache = createGenericCache(20); // Movie history caching and pagination configuration const movieCache = createGenericCache(20); // Cache management functions function isCacheValid() { return progressCache.data.length > 0 && progressCache.allDataLoaded; } // Search functionality function filterProgressDataBySearch(progressData, searchTerm) { if (!searchTerm || searchTerm.trim() === '') { return progressData; } const term = searchTerm.toLowerCase().trim(); return progressData.filter(progress => progress.series.Name.toLowerCase().includes(term) ); } function updateSearchResults(searchTerm) { LOG(`updateSearchResults called with: "${searchTerm}"`); progressCache.searchTerm = searchTerm; // Filter the preloaded progress data using correct property name if (progressCache.allDataLoaded && progressCache.data.length > 0) { progressCache.filteredData = filterProgressDataBySearch(progressCache.data, searchTerm); progressCache.filteredPages = Math.ceil(progressCache.filteredData.length / progressCache.pageSize); LOG(`Progress search: ${progressCache.filteredData.length} results from ${progressCache.data.length} total`); } else { // Fallback - this shouldn't happen with new system, but handle gracefully progressCache.filteredData = []; progressCache.filteredPages = 0; WARN('Progress data not loaded yet, search will be empty'); } LOG(`Search updated: "${searchTerm}" -> ${progressCache.filteredData.length} results, ${progressCache.filteredPages} pages`); // Update pagination immediately after search results change const params = getUrlParams(); if (params.pageTab === 'progress') { const currentPage = params.page || 1; const totalPages = progressPagination.getCurrentTotalPages(); // If current page exceeds total pages after filtering, reset to page 1 if (currentPage > totalPages && totalPages > 0) { LOG(`Current page ${currentPage} exceeds total pages ${totalPages} after search, resetting to page 1`); updateUrlParams('progress', 1); // Update pagination controls with page 1 renderPaginationControls(1, totalPages, 'progress'); } else { // Update pagination controls with current page renderPaginationControls(currentPage, totalPages, 'progress'); } } } // Movie search functionality function filterMoviesBySearch(movies, searchTerm) { if (!searchTerm || searchTerm.trim() === '') { return movies; } const term = searchTerm.toLowerCase().trim(); return movies.filter(movie => { const title = (movie.Name || '').toLowerCase(); const year = movie.ProductionYear || ''; const genres = (movie.Genres || []).join(' ').toLowerCase(); return title.includes(term) || year.toString().includes(term) || genres.includes(term); }); } function updateMovieSearchResults(searchTerm) { LOG(`updateMovieSearchResults called with: "${searchTerm}"`); movieCache.searchTerm = searchTerm; // Filter the cached movie data if (movieCache.data.length > 0) { movieCache.filteredData = filterMoviesBySearch(movieCache.data, searchTerm); movieCache.filteredPages = Math.ceil(movieCache.filteredData.length / movieCache.pageSize); LOG(`Movie search: ${movieCache.filteredData.length} results from ${movieCache.data.length} total`); LOG(`Movie cache state: searchTerm="${movieCache.searchTerm}", filteredData.length=${movieCache.filteredData.length}, filteredPages=${movieCache.filteredPages}`); } else { WARN('No movie data available for search'); } LOG(`Movie search updated: "${searchTerm}" -> ${movieCache.filteredData.length} results, ${movieCache.filteredPages} pages`); // Update pagination immediately after search results change const params = getUrlParams(); if (params.pageTab === 'history') { const currentPage = params.page || 1; const totalPages = moviePagination.getCurrentTotalPages(); // If current page exceeds total pages after filtering, reset to page 1 if (currentPage > totalPages && totalPages > 0) { LOG(`Current page ${currentPage} exceeds total pages ${totalPages} after movie search, resetting to page 1`); updateUrlParams('history', 1); // Update pagination controls with page 1 renderPaginationControls(1, totalPages, 'history'); } else { // Update pagination controls with current page renderPaginationControls(currentPage, totalPages, 'history'); } } } function getCurrentTotalPages() { // Use the new pagination system return progressPagination.getCurrentTotalPages(); } // Individual series progress cache management function cacheSeriesProgress(seriesId, progressData) { // Find existing entry in data array const existingIndex = progressCache.data.findIndex(item => item.series.Id === seriesId); if (existingIndex !== -1) { // Update existing entry progressCache.data[existingIndex] = progressData; } else { // Add new entry progressCache.data.push(progressData); } LOG(`Cached progress data for series: ${progressData.series.Name}`); } function getCachedSeriesProgress(seriesId) { const cachedProgress = progressCache.data.find(item => item && item.series && item.series.Id === seriesId ); return cachedProgress || null; } // Helper function to get series ID from episode ID async function getSeriesIdFromEpisode(episodeId) { // Query API to get episode details and find series ID const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { const episodeUrl = `${serverUrl}/Items/${episodeId}?UserId=${userId}&Fields=ParentId`; const episodeRes = await fetch(episodeUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const episodeData = await episodeRes.json(); // Get the series ID from the episode's parent (season) parent if (episodeData.ParentId) { const seasonUrl = `${serverUrl}/Items/${episodeData.ParentId}?UserId=${userId}&Fields=ParentId`; const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const seasonData = await seasonRes.json(); return seasonData.ParentId || null; } return null; } catch (err) { ERR(`Failed to get series ID for episode ${episodeId}:`, err); return null; } } // Helper function to recalculate series progress metrics function recalculateSeriesProgressMetrics(progressData) { // Only recalculate if we have episodes data in memory if (!progressData.episodes || !Array.isArray(progressData.episodes)) { LOG('No episodes data available for recalculation'); return progressData; } const episodes = progressData.episodes; const today = new Date(); today.setHours(23, 59, 59, 999); // Filter to aired episodes only const airedEpisodes = episodes.filter(ep => ep.PremiereDate && new Date(ep.PremiereDate) <= today ); // Expand multi-part episodes to get the actual total count const expandedForCount = []; airedEpisodes.forEach(episode => { expandedForCount.push(...expandMultiPartEpisodes(episode)); }); progressData.totalEpisodes = expandedForCount.length; // Count watched episodes - expand multi-part episodes and count all parts if watched const watchedEpisodes = airedEpisodes.filter(ep => ep.UserData && ep.UserData.Played === true ); const watchedExpanded = []; watchedEpisodes.forEach(episode => { watchedExpanded.push(...expandMultiPartEpisodes(episode)); }); progressData.watchedCount = watchedExpanded.length; progressData.remainingCount = progressData.totalEpisodes - progressData.watchedCount; progressData.percentage = progressData.totalEpisodes > 0 ? Math.round((progressData.watchedCount / progressData.totalEpisodes) * 100) : 0; // Update last watched episode const lastWatched = watchedEpisodes .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0))[0]; progressData.lastWatchedEpisode = lastWatched ? { Id: lastWatched.Id, Name: lastWatched.Name, IndexNumber: lastWatched.IndexNumber, ParentIndexNumber: lastWatched.ParentIndexNumber, UserData: lastWatched.UserData, ImageTags: lastWatched.ImageTags } : null; LOG(`Recalculated metrics for series: ${progressData.series.Name} - ${progressData.watchedCount}/${progressData.totalEpisodes} (${progressData.percentage}%)`); return progressData; } // Main function to update episode watched status in cache async function updateEpisodeWatchedStatusInCache(seriesId, episodeId, isWatched = true) { let cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress) { LOG(`No cached progress found for series: ${seriesId}, fetching and adding to cache`); // Fetch series data from API const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { // Fetch the series item const seriesUrl = `${serverUrl}/Items/${seriesId}?UserId=${userId}&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`; const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const series = await seriesRes.json(); if (!series || !series.Id) { LOG(`Failed to fetch series data for seriesId: ${seriesId}`); return false; } // Fetch and create progress data for this series cachedProgress = await fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token); if (!cachedProgress) { LOG(`Failed to create progress data for series: ${seriesId}`); return false; } LOG(`Successfully added series ${series.Name} to cache`); } catch (err) { ERR(`Failed to fetch and add series ${seriesId} to cache:`, err); return false; } } // Update episode data if available in memory if (cachedProgress.episodes) { const episode = cachedProgress.episodes.find(ep => ep.Id === episodeId); if (episode) { episode.UserData = episode.UserData || {}; episode.UserData.Played = isWatched; episode.UserData.IsPlayed = isWatched; // Update LastPlayedDate based on watched status if (isWatched) { episode.UserData.LastPlayedDate = new Date().toISOString(); } else { // Clear LastPlayedDate when unwatching episode.UserData.LastPlayedDate = null; } LOG(`Updated episode ${episodeId} watched status to ${isWatched} in memory cache`); } } // Recalculate statistics by actually counting watched episodes // This accounts for multi-part episodes and is more reliable than incrementing/decrementing if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) { // Expand multi-part episodes to get accurate counts const expandedEpisodes = []; cachedProgress.episodes.forEach(ep => { expandedEpisodes.push(...expandMultiPartEpisodes(ep)); }); // Count watched episodes by checking UserData.Played status const watchedCount = cachedProgress.episodes.filter(ep => ep.UserData && ep.UserData.Played === true ).reduce((count, ep) => count + expandMultiPartEpisodes(ep).length, 0); // Total is the count of all expanded episodes const totalEpisodes = expandedEpisodes.length; cachedProgress.watchedCount = watchedCount; cachedProgress.remainingCount = totalEpisodes - watchedCount; cachedProgress.totalEpisodes = totalEpisodes; // Get the last watched episode based on the cachedProgress.episodes const lastWatchedEpisode = cachedProgress.episodes .filter(ep => ep.UserData && ep.UserData.LastPlayedDate) .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate))[0]; if (lastWatchedEpisode) { cachedProgress.lastWatchedEpisode = lastWatchedEpisode; } } // Recalculate percentage cachedProgress.percentage = cachedProgress.totalEpisodes > 0 ? Math.round((cachedProgress.watchedCount / cachedProgress.totalEpisodes) * 100) : 0; // Regenerate binaryProgress from episodes if available if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) { cachedProgress.binaryProgress = generateBinaryProgressData(cachedProgress.episodes); } // Update the cache cacheSeriesProgress(seriesId, cachedProgress); // Update localStorage to persist changes updateProgressDataInLocalStorage(); LOG(`Updated episode ${episodeId} watched status to ${isWatched} in cache for series: ${cachedProgress.series.Name}`); return true; } // Helper function to update episode watched status in cache without localStorage update // Used for bulk operations to avoid multiple localStorage writes function updateEpisodeWatchedStatusInCacheWithoutLocalStorage(seriesId, episodeId, isWatched = true) { const cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress) { LOG(`No cached progress found for series: ${seriesId}`); return false; } // Find and update the specific episode const episode = cachedProgress.episodes.find(ep => ep.Id === episodeId); if (!episode) { LOG(`Episode ${episodeId} not found in cached progress for series: ${seriesId}`); return false; } // Update the episode's watched status episode.UserData = episode.UserData || {}; episode.UserData.Played = isWatched; episode.UserData.IsPlayed = isWatched; // Update LastPlayedDate based on watched status if (isWatched) { episode.UserData.LastPlayedDate = new Date().toISOString(); } else { // Clear LastPlayedDate when unwatching episode.UserData.LastPlayedDate = null; } // Recalculate series metrics recalculateSeriesProgressMetrics(cachedProgress); // Regenerate binaryProgress from episodes if available if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) { cachedProgress.binaryProgress = generateBinaryProgressData(cachedProgress.episodes); } // Update the cache (without localStorage) cacheSeriesProgress(seriesId, cachedProgress); LOG(`Updated episode ${episodeId} watched status to ${isWatched} in cache for series: ${cachedProgress.series.Name} (no localStorage update)`); return true; } // Function to update progress card UI after cache changes async function updateProgressCardUI(seriesId) { // Find the progress card for this series by looking for buttons with data-series-id const seriesButton = document.querySelector(`[data-series-id="${seriesId}"]`); if (!seriesButton) { LOG(`Progress card not found for series: ${seriesId}`); return; } // Find the parent progress card const progressCard = seriesButton.closest('.progress-card'); if (!progressCard) { LOG(`Progress card container not found for series: ${seriesId}`); return; } // Get updated progress data const updatedProgress = getCachedSeriesProgress(seriesId); if (!updatedProgress) { LOG(`No updated progress data found for series: ${seriesId}`); return; } // Update chunked progress bar (preserve enhanced chunks if possible) await updateProgressBarInPlace(progressCard, updatedProgress); // Update percentage text const percentageText = progressCard.querySelector('.progress-percentage'); if (percentageText) { percentageText.textContent = `${updatedProgress.percentage}%`; } // Update stats text const statsText = progressCard.querySelector('.progress-stats'); if (statsText) { statsText.innerHTML = updatedProgress.percentage === 100 ? `Watched ${updatedProgress.watchedCount} of ${updatedProgress.totalEpisodes} episodes - Series Complete!` : `Watched ${updatedProgress.watchedCount} of ${updatedProgress.totalEpisodes} episodes which leaves ${updatedProgress.remainingCount} episodes left to watch.`; } // Update completion badge const completionBadge = progressCard.querySelector('.completion-badge'); if (updatedProgress.percentage === 100) { if (!completionBadge) { const titleElement = progressCard.querySelector('.progress-title'); if (titleElement) { const badge = document.createElement('span'); badge.className = 'completion-badge'; badge.textContent = '✓ Complete'; titleElement.appendChild(badge); } } } else if (completionBadge) { completionBadge.remove(); } // Update progress bar class for completed series const progressBarContainer = progressCard.querySelector('.progress-bar'); if (progressBarContainer) { if (updatedProgress.percentage === 100) { progressBarContainer.classList.add('progress-bar-completed'); } else { progressBarContainer.classList.remove('progress-bar-completed'); } } // Update mark all as watched button visibility const markAllButton = progressCard.querySelector('.mark-all-watched'); if (markAllButton) { if (updatedProgress.remainingCount === 0) { markAllButton.style.display = 'none'; } else { markAllButton.style.display = ''; } } // Update unwatched toggle button visibility const unwatchedToggle = progressCard.querySelector('.unwatched-toggle'); if (unwatchedToggle) { if (updatedProgress.remainingCount === 0) { unwatchedToggle.classList.add('hide'); } else { unwatchedToggle.classList.remove('hide'); } } const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); if (lastWatchedElement) { if (updatedProgress.lastWatchedEpisode) { const episodeInfo = { title: updatedProgress.lastWatchedEpisode.Name, season: updatedProgress.lastWatchedEpisode.ParentIndexNumber, episode: updatedProgress.lastWatchedEpisode.IndexNumber }; updateLastWatched(lastWatchedElement, episodeInfo); } else { // No episodes watched yet lastWatchedElement.innerHTML = ''; } } // Update the unwatched episodes list to remove watched episodes await updateUnwatchedEpisodesList(seriesId); LOG(`Updated UI for series: ${updatedProgress.series.Name} (${updatedProgress.percentage}% complete)`); } // Function to create chunked progress bar showing individual episode status async function createChunkedProgressBar(card, progressData) { const progressBar = card.querySelector('.progress-bar'); if (!progressBar) return; const { totalEpisodes, watchedCount } = progressData; const seriesId = progressData.series.Id; // Check if we have binary progress data if (progressData.binaryProgress) { // Create progress bar from binary data (instant) createBinaryProgressBar(progressBar, progressData.binaryProgress, seriesId); } else { // Fallback to simple progress bar progressBar.innerHTML = `
`; } } // Function to update progress data in localStorage function updateProgressDataInLocalStorage() { if (progressCache.data.length === 0) { LOG('No progress data to update in localStorage'); return; } try { // Optimize the data for storage (same as in fetchAllProgressData) const optimizedData = optimizeProgressDataForStorage(progressCache.data); // Update localStorage with the new data const success = localStorageCache.set('progress_v2', optimizedData); if (success) { LOG('Updated progress data in localStorage'); } else { WARN('Failed to update progress data in localStorage'); } } catch (err) { ERR('Error updating progress data in localStorage:', err); } } // URL parameter handling functions (using Jellyfin's hash format) function getUrlParams() { // Parse hash parameters from the query string part of the hash const hash = window.location.hash; // Check if there are query parameters in the hash if (hash.includes('?')) { const [, queryString] = hash.split('?'); const params = new URLSearchParams(queryString); return { pageTab: params.get('pageTab') || 'watchlist', page: parseInt(params.get('page')) || 1, search: params.get('search') || '', movieSearch: params.get('movieSearch') || '', sort: params.get('sort') || 'lastWatched', sortDirection: params.get('sortDirection') || 'desc', movieSort: params.get('movieSort') || 'lastWatched', movieSortDirection: params.get('movieSortDirection') || 'desc' }; } // No query parameters found, return defaults return { pageTab: 'watchlist', page: 1, search: '', movieSearch: '', sort: 'lastWatched', sortDirection: 'desc', movieSort: 'lastWatched', movieSortDirection: 'desc' }; } function updateUrlParams(pageTab, page, sort = null, sortDirection = null, movieSort = null, movieSortDirection = null) { const currentHash = window.location.hash; let newHash = currentHash; // Get search terms from respective caches const searchTerm = pageTab === 'progress' ? progressCache.searchTerm : ''; const movieSearchTerm = pageTab === 'history' ? movieCache.searchTerm : ''; // Get current sort order and direction or use provided values const currentSort = sort || (pageTab === 'progress' ? getCurrentSortOrder() : null); const currentDirection = sortDirection || (pageTab === 'progress' ? getCurrentSortDirection() : null); const currentMovieSort = movieSort || (pageTab === 'history' ? getCurrentMovieSortOrder() : null); const currentMovieDirection = movieSortDirection || (pageTab === 'history' ? getCurrentMovieSortDirection() : null); if (currentHash.includes('?')) { // Replace existing parameters const [hashPath, hashSearch] = currentHash.split('?'); const params = new URLSearchParams(hashSearch); // Set our parameters params.set('pageTab', pageTab); // Only set page parameter for paginated tabs (progress and history) if (pageTab === 'progress' || pageTab === 'history') { params.set('page', page.toString()); } else { // Remove page parameter for non-paginated tabs (watchlist and statistics) params.delete('page'); } // Update sort parameters for progress tab if (currentSort && pageTab === 'progress') { params.set('sort', currentSort); if (currentDirection) { params.set('sortDirection', currentDirection); } } else if (pageTab !== 'progress') { params.delete('sort'); params.delete('sortDirection'); } // Update movie sort parameters for history tab if (currentMovieSort && pageTab === 'history') { params.set('movieSort', currentMovieSort); if (currentMovieDirection) { params.set('movieSortDirection', currentMovieDirection); } } else if (pageTab !== 'history') { params.delete('movieSort'); params.delete('movieSortDirection'); } // Update search parameters based on tab if (searchTerm && searchTerm.trim() !== '') { params.set('search', searchTerm); } else { params.delete('search'); } if (movieSearchTerm && movieSearchTerm.trim() !== '') { params.set('movieSearch', movieSearchTerm); } else { params.delete('movieSearch'); } const paramString = params.toString(); newHash = paramString ? `${hashPath}?${paramString}` : hashPath; } else { // Add parameters let paramString = `pageTab=${pageTab}`; // Only add page parameter for paginated tabs (progress and history) if (pageTab === 'progress' || pageTab === 'history') { paramString += `&page=${page}`; } // Add sort parameters for progress tab if (currentSort && pageTab === 'progress') { paramString += `&sort=${currentSort}`; if (currentDirection) { paramString += `&sortDirection=${currentDirection}`; } } // Add movie sort parameters for history tab if (currentMovieSort && pageTab === 'history') { paramString += `&movieSort=${currentMovieSort}`; if (currentMovieDirection) { paramString += `&movieSortDirection=${currentMovieDirection}`; } } if (searchTerm && searchTerm.trim() !== '') { paramString += `&search=${encodeURIComponent(searchTerm)}`; } if (movieSearchTerm && movieSearchTerm.trim() !== '') { paramString += `&movieSearch=${encodeURIComponent(movieSearchTerm)}`; } newHash = `${currentHash}?${paramString}`; } // Update cache currentPage for the appropriate tab if (pageTab === 'progress') { progressCache.currentPage = page; } else if (pageTab === 'history') { movieCache.currentPage = page; } const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1); // Replace history entry instead of adding new one window.history.replaceState(null, '', newUrl); LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${searchTerm}", movieSearch="${movieSearchTerm}"`, newUrl); } // Sorting configuration and functions const SORT_OPTIONS = { lastWatched: { label: 'Last Watched', default: true, defaultDirection: 'desc', sortFn: (a, b, direction) => sortByLastWatched(a, b, direction) }, name: { label: 'Name', defaultDirection: 'asc', sortFn: (a, b, direction) => sortByName(a, b, direction) }, progress: { label: 'Progress', defaultDirection: 'desc', sortFn: (a, b, direction) => sortByProgress(a, b, direction) }, episodeCount: { label: 'Episode Count', defaultDirection: 'desc', sortFn: (a, b, direction) => sortByEpisodeCount(a, b, direction) }, releaseDate: { label: 'Release Date', defaultDirection: 'desc', sortFn: (a, b, direction) => sortByReleaseDate(a, b, direction) } }; // Movie-specific sorting configuration const MOVIE_SORT_OPTIONS = { lastWatched: { label: 'Last Watched', default: true, defaultDirection: 'desc', sortFn: (a, b, direction) => sortMovieByLastWatched(a, b, direction) }, name: { label: 'Name', defaultDirection: 'asc', sortFn: (a, b, direction) => sortMovieByName(a, b, direction) }, premiereDate: { label: 'Premiere Date', defaultDirection: 'desc', sortFn: (a, b, direction) => sortMovieByPremiereDate(a, b, direction) }, runtime: { label: 'Runtime', defaultDirection: 'desc', sortFn: (a, b, direction) => sortMovieByRuntime(a, b, direction) } }; // Get current sort order from URL or localStorage function getCurrentSortOrder() { const params = getUrlParams(); if (params.sort && SORT_OPTIONS[params.sort]) { return params.sort; } // Fallback to localStorage const savedSort = localStorage.getItem('kefinTweaks_progressSort'); if (savedSort && SORT_OPTIONS[savedSort]) { return savedSort; } return 'lastWatched'; } // Get current sort direction from URL or localStorage function getCurrentSortDirection() { const params = getUrlParams(); if (params.sortDirection && (params.sortDirection === 'asc' || params.sortDirection === 'desc')) { return params.sortDirection; } // Fallback to localStorage const savedDirection = localStorage.getItem('kefinTweaks_progressSortDirection'); if (savedDirection && (savedDirection === 'asc' || savedDirection === 'desc')) { return savedDirection; } // Default to the sort option's default direction const currentSort = getCurrentSortOrder(); return SORT_OPTIONS[currentSort]?.defaultDirection || 'desc'; } // Movie-specific sort functions function getCurrentMovieSortOrder() { const params = getUrlParams(); if (params.movieSort && MOVIE_SORT_OPTIONS[params.movieSort]) { return params.movieSort; } // Fallback to localStorage const savedSort = localStorage.getItem('kefinTweaks_movieSort'); if (savedSort && MOVIE_SORT_OPTIONS[savedSort]) { return savedSort; } return 'lastWatched'; } function getCurrentMovieSortDirection() { const params = getUrlParams(); if (params.movieSortDirection && (params.movieSortDirection === 'asc' || params.movieSortDirection === 'desc')) { return params.movieSortDirection; } // Fallback to localStorage const savedDirection = localStorage.getItem('kefinTweaks_movieSortDirection'); if (savedDirection && (savedDirection === 'asc' || savedDirection === 'desc')) { return savedDirection; } // Default to the sort option's default direction const currentSort = getCurrentMovieSortOrder(); return MOVIE_SORT_OPTIONS[currentSort]?.defaultDirection || 'desc'; } // Set sort order and update URL function setSortOrder(sortKey, direction = null) { if (!SORT_OPTIONS[sortKey]) { LOG(`Invalid sort key: ${sortKey}`); return; } // Use provided direction or default for the sort option const sortDirection = direction || SORT_OPTIONS[sortKey].defaultDirection; // Save to localStorage localStorage.setItem('kefinTweaks_progressSort', sortKey); localStorage.setItem('kefinTweaks_progressSortDirection', sortDirection); // Update URL - reset to page 1 when sort changes const params = getUrlParams(); updateUrlParams(params.pageTab, 1, sortKey, sortDirection); // Update tab state to reflect page reset if (tabStates.progress) { tabStates.progress.currentPage = 1; } // Re-render content renderProgressContent(); } // Sort function for last watched (individual items) function sortByLastWatched(a, b, direction = 'desc') { // Get last watched dates from progress data const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date // Sort by direction return direction === 'asc' ? dateA - dateB : dateB - dateA; } // Sort function for name function sortByName(a, b, direction = 'asc') { const result = a.series.Name.localeCompare(b.series.Name); return direction === 'asc' ? result : -result; } // Sort function for progress percentage function sortByProgress(a, b, direction = 'desc') { // Primary sort: progress percentage const progressResult = b.percentage - a.percentage; // If progress is the same, use last watched date as tiebreaker if (progressResult === 0) { const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date // For tiebreaker, always use descending (most recent first) regardless of main direction const tiebreakerResult = dateB - dateA; return direction === 'asc' ? -progressResult : progressResult + (tiebreakerResult * 0.0001); } return direction === 'asc' ? -progressResult : progressResult; } // Sort function for episode count function sortByEpisodeCount(a, b, direction = 'desc') { const result = b.totalEpisodes - a.totalEpisodes; return direction === 'asc' ? -result : result; } // Sort function for release date function sortByReleaseDate(a, b, direction = 'desc') { const dateA = new Date(a.series.ProductionYear); const dateB = new Date(b.series.ProductionYear); const result = dateB - dateA; return direction === 'asc' ? -result : result; } // Movie-specific sort functions function sortMovieByLastWatched(a, b, direction = 'desc') { const dateA = a.UserData && a.UserData.LastPlayedDate ? new Date(a.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last played date const dateB = b.UserData && b.UserData.LastPlayedDate ? new Date(b.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last played date // Sort by direction return direction === 'asc' ? dateA - dateB : dateB - dateA; } function sortMovieByName(a, b, direction = 'asc') { const result = a.Name.localeCompare(b.Name); return direction === 'asc' ? result : -result; } function sortMovieByPremiereDate(a, b, direction = 'desc') { const dateA = new Date(a.PremiereDate || a.ProductionYear); const dateB = new Date(b.PremiereDate || b.ProductionYear); const result = dateB - dateA; return direction === 'asc' ? -result : result; } function sortMovieByRuntime(a, b, direction = 'desc') { const runtimeA = a.RunTimeTicks ? Math.floor(a.RunTimeTicks / 10000000) : 0; // Convert ticks to seconds const runtimeB = b.RunTimeTicks ? Math.floor(b.RunTimeTicks / 10000000) : 0; const result = runtimeB - runtimeA; return direction === 'asc' ? -result : result; } // Set movie sort order and update URL function setMovieSortOrder(sortKey, direction = null) { if (!MOVIE_SORT_OPTIONS[sortKey]) { LOG(`Invalid movie sort key: ${sortKey}`); return; } // Use provided direction or default for the sort option const sortDirection = direction || MOVIE_SORT_OPTIONS[sortKey].defaultDirection; // Save to localStorage localStorage.setItem('kefinTweaks_movieSort', sortKey); localStorage.setItem('kefinTweaks_movieSortDirection', sortDirection); // Update URL - reset to page 1 when sort changes const params = getUrlParams(); updateUrlParams(params.pageTab, 1, null, null, sortKey, sortDirection); // Update tab state to reflect page reset if (tabStates.history) { tabStates.history.currentPage = 1; } // Re-render content renderHistoryContent(); } // Apply sorting to data function applySorting(data, sortKey) { if (!SORT_OPTIONS[sortKey]) { LOG(`Invalid sort key: ${sortKey}`); return data; } const sortDirection = getCurrentSortDirection(); const sortFn = SORT_OPTIONS[sortKey].sortFn; const sortedData = [...data].sort((a, b) => sortFn(a, b, sortDirection)); LOG(`Applied sorting: ${SORT_OPTIONS[sortKey].label} (${sortDirection})`); return sortedData; } // Apply sorting to movie data function applyMovieSorting(data, sortKey) { if (!MOVIE_SORT_OPTIONS[sortKey]) { LOG(`Invalid movie sort key: ${sortKey}`); return data; } const sortDirection = getCurrentMovieSortDirection(); const sortFn = MOVIE_SORT_OPTIONS[sortKey].sortFn; const sortedData = [...data].sort((a, b) => sortFn(a, b, sortDirection)); LOG(`Applied movie sorting: ${MOVIE_SORT_OPTIONS[sortKey].label} (${sortDirection})`); return sortedData; } function updateUrlWithSearch(pageTab, page, search = '', movieSearch = '') { const currentHash = window.location.hash; let newHash = currentHash; if (currentHash.includes('?')) { // Replace existing parameters const [hashPath, hashSearch] = currentHash.split('?'); const params = new URLSearchParams(hashSearch); // Set our parameters params.set('pageTab', pageTab); params.set('page', page.toString()); // Add or remove search parameter if (search && search.trim() !== '') { params.set('search', search); } else { params.delete('search'); } // Add or remove movie search parameter if (movieSearch && movieSearch.trim() !== '') { params.set('movieSearch', movieSearch); } else { params.delete('movieSearch'); } const paramString = params.toString(); newHash = paramString ? `${hashPath}?${paramString}` : hashPath; } else { // Add parameters let paramString = `pageTab=${pageTab}&page=${page}`; if (search && search.trim() !== '') { paramString += `&search=${encodeURIComponent(search)}`; } if (movieSearch && movieSearch.trim() !== '') { paramString += `&movieSearch=${encodeURIComponent(movieSearch)}`; } newHash = `${currentHash}?${paramString}`; } // Update cache currentPage for the appropriate tab if (pageTab === 'progress') { progressCache.currentPage = page; } else if (pageTab === 'history') { movieCache.currentPage = page; } const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1); // Replace history entry instead of adding new one window.history.replaceState(null, '', newUrl); LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${search}", movieSearch="${movieSearch}"`, newUrl); } // Hash change listener for browser navigation / in-page tab URL sync function setupHashChangeListener() { window.addEventListener('hashchange', async () => { const [path, search = ''] = String(window.location.hash || '').split('?'); const [watchlistPath, watchlistSearch = ''] = String(window.KefinTweaksUtils._watchlistUrl || '').split('?'); const currentParams = new URLSearchParams(search); const requiredParams = new URLSearchParams(watchlistSearch); if ( !watchlistPath || path !== watchlistPath || Array.from(requiredParams).some(([key, value]) => currentParams.get(key) !== value) ) { return; } LOG('Hash changed, syncing watchlist state'); await renderWatchlist(); const params = getUrlParams(); const pageTab = params.pageTab || 'watchlist'; // Switch sub-tab UI from URL (pageTab query) if (pageTab === 'progress' || pageTab === 'history' || pageTab === 'watchlist' || pageTab === 'statistics') { const watchlistSection = getWatchlistSection(); if (watchlistSection) { const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button'); buttons.forEach(button => button.classList.remove('active')); const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)'); tabContents.forEach(content => content.style.display = 'none'); const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${pageTab}"]`); const activeContent = watchlistSection.querySelector(`div[data-tab="${pageTab}"]:not(.watchlist-tabs)`); if (activeButton) activeButton.classList.add('active'); if (activeContent) activeContent.style.display = 'block'; if (pageTab === 'progress') { initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err)); } else if (pageTab === 'watchlist') { renderWatchlistContent(); } else if (pageTab === 'history') { initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err)); } else if (pageTab === 'statistics') { initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err)); } } } }); } // Tab initialization functions async function initProgressTab() { const watchlistSection = getWatchlistSection(); const progressContent = watchlistSection ? watchlistSection.querySelector('div[data-tab="progress"] .progress-series') : null; // Only skip if we're currently fetching OR if data is fetched AND we have cached data if (tabStates.progress.isFetching || (tabStates.progress.isDataFetched && progressCache.allDataLoaded)) { LOG('Progress tab already initialized or fetching'); return; } const urlParams = getUrlParams(); if (urlParams.search) { const searchInput = getElementByIdSafe('progress-search'); if (searchInput) { searchInput.value = urlParams.search; } } LOG('Initializing progress tab'); renderProgressContent(); try { await fetchAllProgressData(true); tabStates.progress.isDataFetched = true; tabStates.progress.isFetching = false; if (urlParams.search) { updateSearchResults(urlParams.search); } if (movieCache && movieCache.allDataLoaded) { renderStatisticsContent(); } renderProgressContent(); LOG('Progress tab initialization complete'); } catch (err) { ERR('Error initializing progress tab:', err); tabStates.progress.isFetching = false; } } async function initHistoryTab() { if (tabStates.history.isFetching) { LOG('History tab is currently fetching, skipping'); return; } const urlParams = getUrlParams(); if (urlParams.movieSearch) { const searchInput = getElementByIdSafe('movie-search'); if (searchInput) { searchInput.value = urlParams.movieSearch; } } LOG('Initializing history tab'); renderHistoryContent(); try { // Always fetch data (will use cache if available) await fetchWatchedMovies(true); tabStates.history.isDataFetched = true; tabStates.history.isFetching = false; if (urlParams.movieSearch) { updateMovieSearchResults(urlParams.movieSearch); } if (progressCache && progressCache.allDataLoaded) { renderStatisticsContent(); } // Always render content, even if data was already fetched // This ensures content is rendered when tab is reinitialized renderHistoryContent(); LOG('History tab initialization complete'); } catch (err) { ERR('Error initializing history tab:', err); tabStates.history.isFetching = false; } } // Initialize a specific watchlist section (legacy no-op; progressive path owns section loading) async function initWatchlistSection(section, type) { LOG(`initWatchlistSection skipped for ${section}/${type} (progressive render)`); } async function initWatchlistTab(forceRefresh = false) { if (tabStates.watchlist.isFetching) { LOG('Watchlist tab is fetching'); return; } LOG('Initializing watchlist tab'); tabStates.watchlist.isFetching = true; tabStates.watchlist.isDataFetched = false; try { if (forceRefresh) { await invalidateAllWatchlistQueries(); } await renderWatchlistProgressive(forceRefresh); tabStates.watchlist.isDataFetched = true; // Sync watched status to remove played items await syncWatchedStatusToWatchlist(); LOG('Watchlist tab initialization complete'); } catch (err) { ERR('Error initializing watchlist tab:', err); tabStates.watchlist.isDataFetched = false; } finally { tabStates.watchlist.isFetching = false; } } async function initStatisticsTab() { if (tabStates.statistics.isFetching || tabStates.statistics.isDataFetched) { LOG('Statistics tab already initialized or fetching'); return; } LOG('Initializing statistics tab'); tabStates.statistics.isFetching = true; try { await renderStatisticsContent(); tabStates.statistics.isDataFetched = true; LOG('Statistics tab initialization complete'); } catch (err) { ERR('Error initializing statistics tab:', err); } finally { tabStates.statistics.isFetching = false; } } // Initialize tabs lazily — only the active/default tab; progress/history on demand async function initializeAllTabs() { LOG('Starting tab initialization'); const params = getUrlParams(); const pageTab = params.pageTab || 'watchlist'; await initWatchlistTab(); if (pageTab === 'progress') { await initProgressTab(); } else if (pageTab === 'history') { await initHistoryTab(); } else if (pageTab === 'statistics') { await initStatisticsTab(); } LOG('Tab initialization complete (active:', pageTab, ')'); } /** Run async work over items with a fixed concurrency limit. */ async function mapWithConcurrency(items, concurrency, mapper) { const list = Array.isArray(items) ? items : []; const limit = Math.max(1, Number(concurrency) || 1); const results = new Array(list.length); let nextIndex = 0; async function worker() { while (nextIndex < list.length) { const i = nextIndex++; results[i] = await mapper(list[i], i); } } const workers = []; for (let w = 0; w < Math.min(limit, list.length); w++) { workers.push(worker()); } await Promise.all(workers); return results; } function sortInProgressSeries(series) { LOG('Starting to sort series by Status and date'); const sorted = series.sort((a, b) => { // First, separate Continuing vs Ended if (a.Status === 'Continuing' && b.Status === 'Ended') return -1; if (a.Status === 'Ended' && b.Status === 'Continuing') return 1; // Within same status, sort by appropriate date if (a.Status === 'Continuing') { const dateA = new Date(a.PremiereDate || 0); const dateB = new Date(b.PremiereDate || 0); return dateB - dateA; // Newest first } else { // Ended const dateA = new Date(a.EndDate || 0); const dateB = new Date(b.EndDate || 0); return dateB - dateA; // Most recently ended first } }); // Debug logging for sorting results const continuingCount = sorted.filter(s => s.Status === 'Continuing').length; const endedCount = sorted.filter(s => s.Status === 'Ended').length; LOG(`Sorting complete: ${continuingCount} Continuing, ${endedCount} Ended series`); return sorted; } function sortProgressDataByLastWatched(progressDataArray) { LOG('Starting to sort progress data by most recently watched episode'); const sorted = progressDataArray.sort((a, b) => { // Get last watched dates from progress data const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate) : new Date(0); // Fallback to epoch if no last watched date // Sort by most recently watched first (descending order) return dateB - dateA; }); // Debug logging for sorting results const withLastWatched = sorted.filter(p => p.lastWatchedEpisode && p.lastWatchedEpisode.UserData && p.lastWatchedEpisode.UserData.LastPlayedDate).length; const withoutLastWatched = sorted.length - withLastWatched; LOG(`Progress sorting complete: ${withLastWatched} with last watched date, ${withoutLastWatched} without`); return sorted; } async function fetchAllProgressData(useCache = true) { // Check if we're already fetching to prevent duplicate calls if (tabStates.progress.isFetching) { LOG('Progress data fetch already in progress, skipping duplicate call'); return progressCache.data; // Return existing data or empty array } // Check if we already have all progress data loaded if (useCache && progressCache.allDataLoaded) { LOG('Using cached all progress data'); return progressCache.data; } // Check localStorage cache if in-memory cache is empty if (useCache && !progressCache.allDataLoaded) { const cachedData = localStorageCache.get(`progress_v2`); if (cachedData) { LOG('Using localStorage cache for progress data'); progressCache.data = cachedData; progressCache.allDataLoaded = true; progressCache.totalPages = Math.ceil(cachedData.length / progressCache.pageSize); return cachedData; } } // Set fetching flag to prevent duplicate calls tabStates.progress.isFetching = true; LOG('Starting progress data fetch...'); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); // Fetch series via apiHelper try { const seriesData = await window.apiHelper.getItems({ IncludeItemTypes: 'Series', Recursive: true, Fields: 'UserData,RecursiveItemCount', EnableImageTypes: 'Primary,Banner' }, false); const series = seriesData.Items || []; if (series.length === 0) { LOG('No series found, returning empty progress data'); progressCache.data = []; progressCache.allDataLoaded = true; return []; } // Filter series that have been started (UserData.PlayedPercentage > 0) const inProgressSeries = series.filter(series => { return series.UserData && series.UserData.PlayedPercentage > 0; }); LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`); LOG(`Fetching progress data for ${inProgressSeries.length} series (concurrency 4)`); const progressData = await mapWithConcurrency(inProgressSeries, 4, async (seriesItem) => { return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token); }); // Filter out series with no valid progress data const validProgressData = progressData.filter(data => data !== null); LOG(`Successfully processed ${validProgressData.length} progress entries`); // Sort by most recently watched episode const sortedProgressData = sortProgressDataByLastWatched(validProgressData); // Update cache with all progress data progressCache.data = sortedProgressData; progressCache.allDataLoaded = true; progressCache.totalPages = Math.ceil(sortedProgressData.length / progressCache.pageSize); // Store in localStorage for next time (optimized) const optimizedData = optimizeProgressDataForStorage(sortedProgressData); localStorageCache.set(`progress_v2`, optimizedData); LOG('Stored optimized progress data in localStorage'); LOG(`All progress data loaded and sorted: ${sortedProgressData.length} items, ${progressCache.totalPages} pages`); return sortedProgressData; } catch (err) { ERR("Failed to fetch all progress data:", err); return []; } finally { // Always clear the fetching flag, even on error tabStates.progress.isFetching = false; LOG('Progress data fetch completed'); // Do not call renderStatisticsContent here — nested re-entry while the // stats panel is still data-ready=false leaves Chart.js with a 0-size canvas. } } async function fetchProgressForPage(page = 1) { LOG(`Getting progress data for page ${page} from preloaded data`); // Ensure we have all progress data loaded if (!progressCache.allDataLoaded || progressCache.data.length === 0) { await fetchAllProgressData(true); } // Get current sort order and apply sorting const currentSort = getCurrentSortOrder(); const sortedData = applySorting(progressCache.data, currentSort); // Update pagination with sorted data progressPagination.updateCache(sortedData); // Get the progress data for this page from sorted data const { pageData } = progressPagination.getCachedPage(page); return pageData; } async function fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token) { try { // Check if we already have progress data cached for this series const cachedProgress = getCachedSeriesProgress(series.Id); if (cachedProgress) { LOG(`Using cached progress for series: ${series.Name}`); return cachedProgress; } // Get all episodes including missing ones to get accurate TotalRecordCount const episodesUrl = `${serverUrl}/Shows/${series.Id}/Episodes?UserId=${userId}&Fields=UserData,RunTimeTicks&EnableImageTypes=Primary`; const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const episodesData = await episodesRes.json(); let episodes = episodesData.Items || []; episodes = episodes .filter(ep => ep.ParentIndexNumber !== 0) .filter((ep, index, self) => index === self.findIndex(e => e.ParentIndexNumber === ep.ParentIndexNumber && e.IndexNumber === ep.IndexNumber ) ); // Count only episodes that have aired (have PremiereDate and it's before today) const today = new Date(); today.setHours(23, 59, 59, 999); // End of today const airedEpisodes = episodes.filter(ep => ep.PremiereDate && new Date(ep.PremiereDate) <= today ); // Expand multi-part episodes to get the actual total count const expandedForCount = []; airedEpisodes.forEach(episode => { expandedForCount.push(...expandMultiPartEpisodes(episode)); }); const totalEpisodes = expandedForCount.length; // Count watched episodes - expand multi-part episodes and count all parts if watched const watchedExpanded = []; airedEpisodes.forEach(episode => { if (episode.UserData && episode.UserData.Played === true) { watchedExpanded.push(...expandMultiPartEpisodes(episode)); } }); const watchedCount = watchedExpanded.length; const remainingCount = totalEpisodes - watchedCount; // Debug logging to verify we're getting the right data LOG(`Series: ${series.Name}, Aired Episodes: ${totalEpisodes}, Watched: ${watchedCount}, Remaining: ${remainingCount}`); if (totalEpisodes === 0 || watchedCount === 0) return null; // Calculate total runtime from available episodes (only those with actual files) const availableEpisodes = episodes.filter(ep => ep.RunTimeTicks && ep.RunTimeTicks > 0); const totalRuntime = availableEpisodes.reduce((total, ep) => total + (ep.RunTimeTicks / 10000000), 0); // Calculate watched runtime from actually watched episodes const watchedRuntime = watchedExpanded.reduce((total, ep) => total + (ep.RunTimeTicks ? ep.RunTimeTicks / 10000000 : 0), 0); // Estimate remaining runtime (this is approximate since we don't have runtime for missing episodes) const remainingRuntime = totalRuntime - watchedRuntime; // Find last watched episode let lastWatchedEpisode = null; if (watchedExpanded.length > 0) { lastWatchedEpisode = watchedExpanded .filter(ep => ep.UserData && ep.UserData.LastPlayedDate) .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate))[0]; } // Calculate percentage based on total episodes (including missing ones) const percentage = totalEpisodes > 0 ? Math.round((watchedCount / totalEpisodes) * 100) : 0; // Filter unwatched episodes from aired episodes only const unwatchedEpisodes = airedEpisodes.filter(episode => !episode.UserData.Played || episode.UserData.Played === false ); // Generate binary progress data for instant rendering const binaryProgress = generateBinaryProgressData(episodes); const progressData = { series: series, watchedCount: watchedCount, totalEpisodes: totalEpisodes, remainingCount: remainingCount, percentage: percentage, totalRuntime: totalRuntime, watchedRuntime: watchedRuntime, remainingRuntime: remainingRuntime, lastWatchedEpisode: lastWatchedEpisode, // Binary progress data for instant rendering (stored in localStorage) binaryProgress: binaryProgress, // Episodes cached in memory only (not stored in localStorage) episodes: episodes }; // Cache the progress data for this series cacheSeriesProgress(series.Id, progressData); return progressData; } catch (err) { ERR(`Failed to fetch progress for series ${series.Name}:`, err); return null; } } function formatTimeAgo(dateString) { const now = new Date(); const date = new Date(dateString); const diffInSeconds = Math.floor((now - date) / 1000); if (diffInSeconds < 60) return 'just now'; if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`; if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`; if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`; if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`; return `${Math.floor(diffInSeconds / 31536000)} years ago`; } function formatLastWatchedDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } async function toggleMovieFavorite(movieId, buttonElement) { LOG(`Toggling favorite for movie: ${movieId}`); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { // Determine current favorite status from button state const isCurrentlyFavorite = buttonElement.classList.contains('favorited'); const method = isCurrentlyFavorite ? 'DELETE' : 'POST'; // Make the appropriate API call const toggleUrl = `${serverUrl}/Users/${userId}/FavoriteItems/${movieId}`; const toggleRes = await fetch(toggleUrl, { method: method, headers: { "Authorization": window.apiHelper.getAuthHeader() } }); if (!toggleRes.ok) { throw new Error(`Failed to ${isCurrentlyFavorite ? 'remove from' : 'add to'} favorites: ${toggleRes.status}`); } // Update button appearance based on the new state (opposite of current) const icon = buttonElement.querySelector('.material-icons'); const text = buttonElement.querySelector('span:last-child'); const newFavoriteStatus = !isCurrentlyFavorite; if (newFavoriteStatus) { buttonElement.classList.add('favorited'); } else { buttonElement.classList.remove('favorited'); } LOG(`Successfully ${newFavoriteStatus ? 'added to' : 'removed from'} favorites: ${movieId}`); } catch (err) { ERR('Error toggling movie favorite:', err); // You could add a toast notification here if desired } } // Statistics calculation functions function sumWatchedRuntimeHours(progressData, movies) { let totalTicks = 0; (movies || []).forEach((movie) => { totalTicks += Number(movie.RunTimeTicks) || 0; }); (progressData || []).forEach((progress) => { const source = Array.isArray(progress.episodes) && progress.episodes.length ? progress.episodes : (progress.playEvents || []); source.forEach((ep) => { const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate; const played = ep?.UserData ? ep.UserData.Played === true : !!key; if (!played) return; totalTicks += Number(ep.RunTimeTicks) || 0; }); }); return Math.round(totalTicks / 10000000 / 3600); } async function calculateProgressStatistics(progressData) { if (!progressData || progressData.length === 0) { // Still try to get movie count even if no series data await fetchWatchedMovies(true); // Use cached data return { seriesStarted: 0, seriesWatched: 0, episodesWatched: 0, moviesWatched: movieCache.data.length, hoursWatched: sumWatchedRuntimeHours([], movieCache.data), topShows: [] }; } // Calculate basic statistics const seriesStarted = progressData.length; const seriesWatched = progressData.filter(progress => progress.percentage === 100).length; const episodesWatched = progressData.reduce((total, progress) => total + progress.watchedCount, 0); // Get movie count from cache await fetchWatchedMovies(true); // Use cached data const moviesWatched = movieCache.data.length; const hoursWatched = sumWatchedRuntimeHours(progressData, movieCache.data); // Top 5 shows by total watch time (RunTimeTicks), then episode count const topShows = mapProgressToTopShows(progressData, 5); return { seriesStarted, seriesWatched, episodesWatched, moviesWatched, hoursWatched, topShows }; } async function updateProgressStatistics(progressData) { const stats = await calculateProgressStatistics(progressData); // Update stat cards const seriesStartedEl = getElementByIdSafe('stat-series-started'); const seriesWatchedEl = getElementByIdSafe('stat-series-watched'); const episodesWatchedEl = getElementByIdSafe('stat-episodes-watched'); const moviesWatchedEl = getElementByIdSafe('stat-movies-watched'); const hoursWatchedEl = getElementByIdSafe('stat-hours-watched'); if (seriesStartedEl) seriesStartedEl.textContent = stats.seriesStarted.toLocaleString(); if (seriesWatchedEl) seriesWatchedEl.textContent = stats.seriesWatched.toLocaleString(); if (episodesWatchedEl) episodesWatchedEl.textContent = stats.episodesWatched.toLocaleString(); if (moviesWatchedEl) moviesWatchedEl.textContent = stats.moviesWatched.toLocaleString(); if (hoursWatchedEl) hoursWatchedEl.textContent = stats.hoursWatched.toLocaleString(); // Update top shows list updateTopShowsList(stats.topShows); LOG(`Statistics updated: ${stats.seriesStarted} started, ${stats.seriesWatched} watched, ${stats.episodesWatched} episodes, ${stats.moviesWatched} movies, ${stats.hoursWatched} hours`); } function updateTopShowsList(topShows) { const topShowsList = getElementByIdSafe('top-shows-list'); if (!topShowsList) return; if (topShows.length === 0) { topShowsList.innerHTML = '
No shows watched yet
'; return; } const showsHtml = topShows.map((show, index) => { const rank = index + 1; const rankClass = rank <= 3 ? ` rank-${rank}` : ''; const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes'; const timeLabel = formatWatchedRuntimeLabel(show.watchedRuntimeTicks); const progressLabel = window.innerWidth >= 900 ? `${timeLabel} · ${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)` : `${timeLabel}`; return `
${rank}
${escapeHtmlWatchlist(show.name)}
${progressLabel}
`; }).join(''); topShowsList.innerHTML = showsHtml; } function updateProgressStats() { const progressCountEl = getElementByIdSafe('progress-count'); const completedCountEl = getElementByIdSafe('completed-count'); if (!progressCountEl || !completedCountEl) return; // Always use full, non-filtered progress data for stats const fullData = progressCache.data; // Calculate counts let inProgressCount = 0; let completedCount = 0; fullData.forEach(progress => { if (progress.percentage >= 100) { completedCount++; } else if (progress.percentage > 0) { inProgressCount++; } }); // Update the display progressCountEl.textContent = `${inProgressCount} In Progress`; completedCountEl.textContent = `${completedCount} Watched`; LOG(`Progress stats updated: ${inProgressCount} in progress, ${completedCount} completed (from full dataset)`); } async function renderProgressContent() { if (tabStates.progress.isRendering) { return; } tabStates.progress.isRendering = true; const watchlistSection = getWatchlistSection(); const progressTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="progress"]') : null; const paginatedContainer = progressTab ? progressTab.querySelector('.paginated-container') : null; const container = paginatedContainer ? paginatedContainer.querySelector('.progress-series') : null; if (!container) { WARN("Progress container not found"); return; } try { // Check if we're currently fetching data or data hasn't been fetched yet if (tabStates.progress.isFetching || !tabStates.progress.isDataFetched) { hideEmptyProgressMessage(); container.innerHTML = '
Loading progress data...
'; return; } // Get current page and search term from URL const params = getUrlParams(); const currentPage = (params.pageTab === 'progress') ? (params.page || 1) : (progressCache.currentPage || 1); const searchTerm = params.search || ''; // Get progress data for current page from preloaded data const pageProgress = await fetchProgressForPage(currentPage); const searchInput = getElementByIdSafe('progress-search'); if (searchInput && params.pageTab === 'progress') { searchInput.value = searchTerm; } // Check if we should skip rendering if (shouldSkipRendering('progress', currentPage, searchTerm, container, pageProgress)) { LOG(`Skipping progress render - same page (${currentPage}), search ("${searchTerm}"), and content`); // Still need to ensure pagination controls are rendered const totalPages = progressCache.searchTerm ? progressCache.filteredPages : progressCache.totalPages; if (totalPages > 1) { renderPaginationControls(currentPage, totalPages, 'progress'); } return; } // Restore search state if URL has search parameter if (searchTerm && searchTerm !== progressCache.searchTerm) { const clearBtn = getElementByIdSafe('progress-search-clear'); if (searchInput) { searchInput.value = searchTerm; updateSearchResults(searchTerm); if (clearBtn) { clearBtn.style.display = 'flex'; } } } LOG(`Rendering progress content for page ${currentPage}`); // Ensure we have all progress data loaded await fetchAllProgressData(true); // Get total pages from current data (filtered if searching) const totalPages = getCurrentTotalPages(); // Update statistics with all progress data (not just current page) updateProgressStatistics(progressCache.data); if (!pageProgress || pageProgress.length === 0) { // Hide any existing content and show appropriate empty message container.innerHTML = ''; if (progressCache.searchTerm && progressCache.searchTerm.trim() !== '') { showEmptySearchMessage(); } else { showEmptyProgressMessage(); } return; } // Hide empty message and clear container hideEmptyProgressMessage(); container.innerHTML = ''; // Create progress cards asynchronously for (const progress of pageProgress) { const progressCard = await createProgressCard(progress); container.appendChild(progressCard); } // Update progress stats updateProgressStats(); // Add pagination controls to both top and bottom renderPaginationControls(currentPage, totalPages, 'progress'); // Update cache currentPage progressCache.currentPage = currentPage; // Update tab state after successful render updateTabState('progress', currentPage, searchTerm); LOG(`Rendered ${pageProgress.length} progress items for page ${currentPage} of ${totalPages}`); } catch (err) { ERR('Error rendering progress content:', err); // Clear container and show empty message on error container.innerHTML = ''; clearTabState('progress'); showEmptyProgressMessage(); } finally { tabStates.progress.isRendering = false; } } // Centralized pagination system function createPaginationSystem(cache) { return { isCacheValid: () => cache.data.length > 0, clearCache: () => { cache.data = []; cache.filteredData = []; cache.totalPages = 0; cache.sorted = false; cache.searchTerm = ''; cache.filteredPages = 0; cache.allDataLoaded = false; LOG('Cache cleared'); }, updateCache: (data) => { cache.data = data; cache.totalPages = Math.ceil(data.length / cache.pageSize); cache.sorted = true; cache.allDataLoaded = true; LOG(`Cache updated: ${data.length} items, ${cache.totalPages} pages`); }, getCurrentData: () => { return cache.searchTerm ? cache.filteredData : cache.data; }, getCurrentTotalPages: () => { return cache.searchTerm ? cache.filteredPages : cache.totalPages; }, getCachedPage: (page = 1) => { const currentData = cache.searchTerm ? cache.filteredData : cache.data; const totalPages = cache.searchTerm ? cache.filteredPages : cache.totalPages; const startIndex = (page - 1) * cache.pageSize; const endIndex = startIndex + cache.pageSize; const pageData = currentData.slice(startIndex, endIndex); LOG(`Retrieved page ${page}: ${pageData.length} items (${startIndex}-${endIndex}) from ${currentData.length} total`); return { pageData, totalPages, currentPage: page }; } }; } // Create pagination systems const progressPagination = createPaginationSystem(progressCache); const moviePagination = createPaginationSystem(movieCache); // Lightweight in-memory index of last progressive watchlist paint (for stats / membership checks) const watchlistItemsByType = { Movie: [], Series: [], Season: [], Episode: [], BoxSet: [], Playlist: [], Video: [] }; const WATCHLIST_SECTION_TYPES = [ { type: 'Movie', key: 'movies', label: 'Movies' }, { type: 'Series', key: 'series', label: 'Shows' }, { type: 'Season', key: 'seasons', label: 'Seasons' }, { type: 'Episode', key: 'episodes', label: 'Episodes' }, { type: 'BoxSet', key: 'boxsets', label: 'Collections' }, { type: 'Playlist', key: 'playlists', label: 'Playlists' }, { type: 'Video', key: 'homevideos', label: 'Home Videos' } ]; const WATCHLIST_SUPPORTED_TYPES = WATCHLIST_SECTION_TYPES.map(({ type }) => type); const WATCHLIST_SUPPORTED_TYPES_CSV = WATCHLIST_SUPPORTED_TYPES.join(','); /** Session Id set for overlay button state only (not Watchlist tab rendering). */ const watchlistLikedIds = new Set(); let watchlistLikedIdsLoaded = false; let watchlistLikedIdsLoadPromise = null; let watchlistLikedIdsBaselineScheduled = false; const WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS = 15000; function isWatchlistSupportedType(itemType) { return WATCHLIST_SUPPORTED_TYPES.includes(itemType); } function normalizeServerAddressForStorage(serverAddress) { if (!serverAddress) return ''; return String(serverAddress).replace(/\/+$/, ''); } function getLikedIdsStorageKey() { const userId = window.ApiClient?.getCurrentUserId?.(); const serverAddress = normalizeServerAddressForStorage(window.ApiClient?.serverAddress?.()); if (!userId || !serverAddress) return null; return `kefinTweaks_watchlistLikedIds_${userId}_${encodeURIComponent(serverAddress)}`; } function readLikedIdsFromStorage() { const key = getLikedIdsStorageKey(); if (!key) return null; try { const raw = localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return null; return parsed.filter((id) => typeof id === 'string' && id); } catch (err) { WARN('Failed to read watchlist liked Ids from localStorage:', err); return null; } } function writeLikedIdsToStorage() { const key = getLikedIdsStorageKey(); if (!key) return; try { localStorage.setItem(key, JSON.stringify([...watchlistLikedIds])); } catch (err) { WARN('Failed to write watchlist liked Ids to localStorage:', err); } } function hydrateWatchlistLikedIdsFromStorage() { const ids = readLikedIdsFromStorage(); if (!ids) return false; watchlistLikedIds.clear(); ids.forEach((id) => watchlistLikedIds.add(id)); watchlistLikedIdsLoaded = true; LOG(`Hydrated watchlist liked Id cache from localStorage: ${watchlistLikedIds.size} items`); return true; } function waitForDocumentComplete() { if (document.readyState === 'complete') return Promise.resolve(); return new Promise((resolve) => { window.addEventListener('load', () => resolve(), { once: true }); }); } function isItemInLikedIdCache(itemId) { return !!itemId && watchlistLikedIds.has(itemId); } function setLikedIdInCache(itemId, isLiked) { if (!itemId) return; if (isLiked) watchlistLikedIds.add(itemId); else watchlistLikedIds.delete(itemId); writeLikedIdsToStorage(); } function mergeLikedIdsFromItems(items) { const list = Array.isArray(items) ? items : (items?.Items || []); list.forEach((item) => { if (item?.Id) watchlistLikedIds.add(item.Id); }); writeLikedIdsToStorage(); } function applyWatchlistButtonActiveState(button, isActive) { if (!button) return; button.dataset.active = isActive ? 'true' : 'false'; button.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; } function syncOverlayWatchlistButtons() { document.querySelectorAll('.watchlist-button[data-id]').forEach((button) => { const itemId = button.getAttribute('data-id'); applyWatchlistButtonActiveState(button, isItemInLikedIdCache(itemId)); }); } /** Network refresh of liked-Id set (always runs when scheduled; not skipped after hydrate). */ async function refreshWatchlistLikedIdsFromNetwork() { if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise; watchlistLikedIdsLoadPromise = (async () => { try { if (window.userHelper?.waitForLogin) { await window.userHelper.waitForLogin(); } const apiHelper = window.apiHelper; if (!apiHelper?.getWatchlistItems) { WARN('apiHelper.getWatchlistItems unavailable; liked Id cache empty'); return watchlistLikedIds; } const result = await apiHelper.getWatchlistItems({ IncludeItemTypes: WATCHLIST_SUPPORTED_TYPES_CSV, Limit: 1000 }, true); watchlistLikedIds.clear(); mergeLikedIdsFromItems(result); watchlistLikedIdsLoaded = true; writeLikedIdsToStorage(); LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`); syncOverlayWatchlistButtons(); processExistingOverlayContainers(); } catch (err) { ERR('Failed to load watchlist liked Id baseline:', err); } finally { watchlistLikedIdsLoadPromise = null; } return watchlistLikedIds; })(); return watchlistLikedIdsLoadPromise; } async function scheduleWatchlistLikedIdsBaseline() { if (watchlistLikedIdsBaselineScheduled) return; watchlistLikedIdsBaselineScheduled = true; try { if (window.userHelper?.waitForLogin) { await window.userHelper.waitForLogin(); } if (hydrateWatchlistLikedIdsFromStorage()) { syncOverlayWatchlistButtons(); processExistingOverlayContainers(); } await waitForDocumentComplete(); await new Promise((resolve) => setTimeout(resolve, WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS)); await refreshWatchlistLikedIdsFromNetwork(); } catch (err) { ERR('Failed to schedule watchlist liked Id baseline:', err); } } let watchlistRenderGeneration = 0; function resetWatchlistItemsByType() { WATCHLIST_SECTION_TYPES.forEach(({ type }) => { watchlistItemsByType[type] = []; }); } /** * Keep paint-snapshot membership + header counts in sync on like/unlike without re-rendering sections. * Ghost cards may remain in the DOM; counts reflect liked membership. */ function patchWatchlistItemsByTypeMembership(itemId, itemType, isAdded) { if (!itemId) return; const removeFromType = (type) => { const list = watchlistItemsByType[type]; if (!Array.isArray(list) || !list.length) return false; const next = list.filter((item) => item?.Id !== itemId); if (next.length === list.length) return false; watchlistItemsByType[type] = next; return true; }; if (isAdded) { const type = itemType && isWatchlistSupportedType(itemType) ? itemType : null; if (!type) return; const list = watchlistItemsByType[type] || (watchlistItemsByType[type] = []); if (list.some((item) => item?.Id === itemId)) return; list.push({ Id: itemId }); } else if (itemType && isWatchlistSupportedType(itemType)) { removeFromType(itemType); } else { WATCHLIST_SECTION_TYPES.forEach(({ type }) => removeFromType(type)); } updateWatchlistHeaderStats(); } function getWatchlistProgressiveHost() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return null; let host = watchlistSection.querySelector('.watchlist-progressive-results'); if (!host) { const tab = watchlistSection.querySelector('div[data-tab="watchlist"]'); if (!tab) return null; host = document.createElement('div'); host.className = 'watchlist-progressive-results'; const emptyMsg = tab.querySelector('.watchlist-empty-message'); if (emptyMsg) tab.insertBefore(host, emptyMsg); else tab.appendChild(host); } return host; } function postProcessWatchlistItems(data) { const items = Array.isArray(data) ? data.slice() : (Array.isArray(data?.Items) ? data.Items.slice() : []); return items.sort((a, b) => { const dateA = new Date(a.PremiereDate || a.ProductionYear || 0); const dateB = new Date(b.PremiereDate || b.ProductionYear || 0); return dateB - dateA; }); } async function buildWatchlistProgressiveSection(itemType, label, order, forceRefresh = false) { const apiHelper = window.apiHelper; if (!apiHelper?.getWatchlistQuery) { throw new Error('apiHelper.getWatchlistQuery unavailable'); } // Same Fields as apiHelper.buildWatchlistQueryDef so invalidate hits this fetch URL const queryOptions = { IncludeItemTypes: itemType, Fields: apiHelper.WATCHLIST_PROGRESSIVE_FIELDS || 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData' }; const queryResult = await apiHelper.getWatchlistQuery(queryOptions, { ttl: WATCHLIST_CACHE_TTL, forceRefresh }); const sectionConfig = { id: `watchlist-${itemType}`, name: label, enabled: true, order, ttl: WATCHLIST_CACHE_TTL, userConfigurable: false, queries: [{ queryOptions: { Filters: 'Likes', IncludeItemTypes: itemType, Recursive: true, ImageTypeLimit: 1, EnableImageTypes: 'Primary,Backdrop,Thumb', ...queryOptions, SortBy: 'PremiereDate', SortOrder: 'Descending' } }] }; let mappedDataPromise = null; const ensureData = () => { if (!mappedDataPromise) { const raw = typeof queryResult.ensureData === 'function' ? queryResult.ensureData() : queryResult.dataPromise; mappedDataPromise = Promise.resolve(raw) .then((data) => postProcessWatchlistItems(data)) .catch((err) => { ERR('watchlist ensureData failed for', itemType, err); return []; }); } return mappedDataPromise; }; const result = { data: postProcessWatchlistItems(queryResult.data), isStale: queryResult.isStale === true, isStalePromise: queryResult.isStalePromise, ensureData }; Object.defineProperty(result, 'dataPromise', { configurable: true, enumerable: true, get() { return ensureData(); } }); return { config: sectionConfig, result, itemType }; } async function invalidateAllWatchlistQueries() { const apiHelper = window.apiHelper; if (!apiHelper?.invalidateWatchlistQueries) return; await Promise.all( WATCHLIST_SECTION_TYPES.map(({ type }) => apiHelper.invalidateWatchlistQueries({ IncludeItemTypes: type }) ) ); } async function renderWatchlistProgressive(forceRefresh = false) { const host = getWatchlistProgressiveHost(); if (!host) { ERR('Watchlist progressive host not found'); return; } if (!window.cardBuilder?.renderProgressiveSections) { ERR('cardBuilder.renderProgressiveSections unavailable'); return; } const generation = ++watchlistRenderGeneration; hideEmptyWatchlistMessage(); const sectionPromises = WATCHLIST_SECTION_TYPES.map(({ type, label }, index) => buildWatchlistProgressiveSection(type, label, index, forceRefresh).catch((err) => { ERR('Failed to build watchlist section for', type, err); return null; }) ); const sections = await Promise.all(sectionPromises); if (generation !== watchlistRenderGeneration) { LOG('Skipping stale watchlist render', { generation, watchlistRenderGeneration }); return; } const validSections = sections.filter(Boolean); host.innerHTML = ''; await window.cardBuilder.renderProgressiveSections( host, validSections.map((section) => Promise.resolve(section)), { showStaleDataBeforeRefresh: true } ); resetWatchlistItemsByType(); await Promise.all(validSections.map(async (section) => { const items = await section.result.ensureData(); const list = Array.isArray(items) ? items : (items?.Items || []); watchlistItemsByType[section.itemType] = list; mergeLikedIdsFromItems(list); })); syncOverlayWatchlistButtons(); if (generation !== watchlistRenderGeneration) return; updateWatchlistHeaderStats(); const totalItems = WATCHLIST_SECTION_TYPES.reduce( (sum, { type }) => sum + (watchlistItemsByType[type]?.length || 0), 0 ); if (totalItems === 0) showEmptyWatchlistMessage(); else hideEmptyWatchlistMessage(); } // Function to render the complete watchlist HTML structure function renderWatchlistHtml() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) { WARN('Watchlist section not found, cannot render HTML'); return; } // Check if HTML is already rendered if (watchlistSection.dataset.htmlRendered === 'true') { LOG('Watchlist HTML already rendered, skipping'); return; } LOG('Rendering watchlist HTML structure'); watchlistSection.innerHTML = `

My Watchlist

Series Progress

0 Watched
0 In Progress

Movie Watched History

0 Watched
Loading statistics data...
0
Movies Watched
0
Series Watched
0
Episodes Watched
0
Series Started
0
Hours Watched
Top 5 Shows
`; // Mark as rendered to prevent duplicate rendering watchlistSection.dataset.htmlRendered = 'true'; // Reset listeners setup flag since we have new DOM elements watchlistSection.dataset.listenersSetup = 'false'; // Bind Show All immediately on the fresh button (does not wait for tab listener pass) bindShowAllShowsButton(watchlistSection); // Add refresh buttons after HTML is rendered addRefreshButtons(); LOG('Watchlist HTML structure rendered successfully'); } /************ Import/Export Functions ************/ // Show import/export modal function showImportExportModal() { const modalContent = document.createElement('div'); modalContent.className = 'import-export-modal-content'; modalContent.style.minWidth = '500px'; modalContent.style.maxWidth = '800px'; // Create tab buttons const tabContainer = document.createElement('div'); tabContainer.className = 'import-export-tabs'; tabContainer.style.display = 'flex'; tabContainer.style.gap = '8px'; tabContainer.style.marginBottom = '20px'; tabContainer.style.borderBottom = '1px solid rgba(255, 255, 255, 0.1)'; const exportTabBtn = document.createElement('button'); exportTabBtn.className = 'import-export-tab active'; exportTabBtn.textContent = 'Export'; exportTabBtn.style.cssText = 'flex: 1; padding: 12px; background: transparent; border: none; border-bottom: 2px solid #00a4dc; color: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 14px; font-weight: 500;'; const importTabBtn = document.createElement('button'); importTabBtn.className = 'import-export-tab'; importTabBtn.textContent = 'Import'; importTabBtn.style.cssText = 'flex: 1; padding: 12px; background: transparent; border: none; border-bottom: 2px solid transparent; color: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 14px; font-weight: 500;'; const syncTabBtn = document.createElement('button'); syncTabBtn.className = 'import-export-tab'; syncTabBtn.textContent = 'Playlist Sync'; syncTabBtn.style.cssText = 'flex: 1; padding: 12px; background: transparent; border: none; border-bottom: 2px solid transparent; color: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 14px; font-weight: 500;'; // Tab content container const tabContent = document.createElement('div'); tabContent.className = 'import-export-tab-content'; tabContent.style.minHeight = '400px'; // Active tab state let activeTab = 'export'; // Store exported data to preserve across tab switches let savedExportData = ''; // Playlist sync cache so we only fetch once per modal open let playlistSyncState = { playlists: null, isLoading: false }; // Tab switching const switchTab = (tab) => { // Save export data before clearing if (activeTab === 'export') { const exportTextarea = tabContent.querySelector('#export-textarea'); if (exportTextarea) { savedExportData = exportTextarea.value; } } activeTab = tab; exportTabBtn.classList.toggle('active', tab === 'export'); importTabBtn.classList.toggle('active', tab === 'import'); syncTabBtn.classList.toggle('active', tab === 'sync'); exportTabBtn.style.borderBottomColor = tab === 'export' ? '#00a4dc' : 'transparent'; exportTabBtn.style.color = tab === 'export' ? '#fff' : 'rgba(255, 255, 255, 0.7)'; importTabBtn.style.borderBottomColor = tab === 'import' ? '#00a4dc' : 'transparent'; importTabBtn.style.color = tab === 'import' ? '#fff' : 'rgba(255, 255, 255, 0.7)'; syncTabBtn.style.borderBottomColor = tab === 'sync' ? '#00a4dc' : 'transparent'; syncTabBtn.style.color = tab === 'sync' ? '#fff' : 'rgba(255, 255, 255, 0.7)'; tabContent.innerHTML = ''; if (tab === 'export') { renderExportTab(tabContent, savedExportData, (newData) => { savedExportData = newData; }); } else if (tab === 'import') { renderImportTab(tabContent); } else if (tab === 'sync') { renderPlaylistSyncTab(tabContent, playlistSyncState); } }; exportTabBtn.onclick = () => switchTab('export'); importTabBtn.onclick = () => switchTab('import'); syncTabBtn.onclick = () => switchTab('sync'); tabContainer.appendChild(exportTabBtn); tabContainer.appendChild(importTabBtn); tabContainer.appendChild(syncTabBtn); modalContent.appendChild(tabContainer); modalContent.appendChild(tabContent); // Render default tab (export) renderExportTab(tabContent, savedExportData, (newData) => { savedExportData = newData; }); // Create modal const modal = window.ModalSystem.create({ id: 'import-export-modal', content: modalContent, closeOnBackdrop: true, closeOnEscape: true }); } // Render export tab function renderExportTab(container, savedData = '', updateSavedData = null) { container.innerHTML = ''; const description = document.createElement('p'); description.style.cssText = 'color: rgba(255, 255, 255, 0.7); margin-bottom: 20px; font-size: 14px; line-height: 1.5;'; description.textContent = 'Export your watchlist to a JSON file. The export includes item names and provider IDs (IMDb, TMDB, TVDB).'; container.appendChild(description); const exportBtn = document.createElement('button'); exportBtn.className = 'emby-button emby-button-raised button-submit'; exportBtn.textContent = 'Export Watchlist'; exportBtn.style.cssText = 'margin-bottom: 20px;'; exportBtn.style.alignSelf = 'flex-start'; const textarea = document.createElement('textarea'); textarea.id = 'export-textarea'; textarea.readOnly = true; textarea.style.cssText = 'width: 100%; min-height: 300px; padding: 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; color: #fff; font-family: monospace; font-size: 12px; resize: vertical; box-sizing: border-box;'; textarea.placeholder = 'Exported watchlist data will appear here...'; // Preserve saved data if switching tabs if (savedData) { textarea.value = savedData; } container.appendChild(exportBtn); container.appendChild(textarea); const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; gap: 10px; margin-top: 10px;'; const copyBtn = document.createElement('button'); copyBtn.className = 'emby-button'; copyBtn.textContent = 'Copy to Clipboard'; copyBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; cursor: pointer; font-size: 14px;'; // Enable copy button if there's saved content copyBtn.disabled = !savedData; copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(textarea.value); copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy to Clipboard'; }, 2000); } catch (err) { ERR('Failed to copy to clipboard:', err); alert('Failed to copy to clipboard. Please copy manually.'); } }; buttonContainer.appendChild(copyBtn); container.appendChild(buttonContainer); exportBtn.onclick = async () => { exportBtn.disabled = true; exportBtn.textContent = 'Exporting...'; textarea.value = ''; try { const exportData = await exportWatchlistData(); const jsonString = JSON.stringify(exportData, null, 2); textarea.value = jsonString; // Update saved data so it persists across tab switches if (updateSavedData) { updateSavedData(jsonString); } copyBtn.disabled = false; exportBtn.disabled = false; exportBtn.textContent = 'Export Watchlist'; } catch (err) { ERR('Export failed:', err); textarea.value = `Error exporting watchlist: ${err.message}`; // Clear saved data on error if (updateSavedData) { updateSavedData(''); } exportBtn.disabled = false; exportBtn.textContent = 'Export Watchlist'; } }; } // Render import tab function renderImportTab(container) { container.innerHTML = ''; const description = document.createElement('p'); description.style.cssText = 'color: rgba(255, 255, 255, 0.7); margin-bottom: 20px; font-size: 14px; line-height: 1.5;'; description.textContent = 'Paste your watchlist JSON data below. Each item must have at least one provider ID (IMDb, TMDB, or TVDB).'; container.appendChild(description); const textarea = document.createElement('textarea'); textarea.id = 'import-textarea'; textarea.style.cssText = 'width: 100%; min-height: 200px; padding: 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; color: #fff; font-family: monospace; font-size: 12px; resize: vertical; box-sizing: border-box; margin-bottom: 10px;'; textarea.placeholder = 'Paste your watchlist JSON data here...'; container.appendChild(textarea); const validationMsg = document.createElement('div'); validationMsg.id = 'import-validation'; validationMsg.style.cssText = 'margin-bottom: 10px; padding: 10px; border-radius: 4px; font-size: 13px; display: none;'; container.appendChild(validationMsg); const previewContainer = document.createElement('div'); previewContainer.id = 'import-preview'; previewContainer.style.cssText = 'margin-bottom: 10px; max-height: 200px; overflow-y: auto; display: none;'; container.appendChild(previewContainer); const progressContainer = document.createElement('div'); progressContainer.id = 'import-progress'; progressContainer.style.cssText = 'margin-bottom: 10px; display: none;'; container.appendChild(progressContainer); const summaryContainer = document.createElement('div'); summaryContainer.id = 'import-summary'; summaryContainer.style.cssText = 'margin-bottom: 10px; padding: 10px; border-radius: 4px; display: none;'; container.appendChild(summaryContainer); const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; gap: 10px; margin-top: 10px;'; const validateBtn = document.createElement('button'); validateBtn.className = 'emby-button'; validateBtn.textContent = 'Validate & Preview'; validateBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; cursor: pointer; font-size: 14px;'; const importBtn = document.createElement('button'); importBtn.className = 'emby-button emby-button-raised button-submit'; importBtn.textContent = 'Import Watchlist'; importBtn.disabled = true; importBtn.style.display = 'none'; // Hide until validated const clearBtn = document.createElement('button'); clearBtn.className = 'emby-button'; clearBtn.textContent = 'Clear Watchlist'; clearBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 0, 0, 0.2); color: #ff6b6b; border: 1px solid rgba(255, 0, 0, 0.3); border-radius: 4px; cursor: pointer; font-size: 14px; margin-left: auto;'; buttonContainer.appendChild(validateBtn); buttonContainer.appendChild(importBtn); buttonContainer.appendChild(clearBtn); container.appendChild(buttonContainer); let validatedData = null; validateBtn.onclick = () => { const jsonText = textarea.value.trim(); if (!jsonText) { showValidationMessage(validationMsg, 'Please paste JSON data first.', 'error'); return; } // Hide progress container when validating progressContainer.style.display = 'none'; summaryContainer.style.display = 'none'; try { const data = JSON.parse(jsonText); const validation = validateImportData(data); if (validation.valid) { validatedData = data; showValidationMessage(validationMsg, validation.message, 'success'); showPreview(previewContainer, data); importBtn.disabled = false; importBtn.style.display = ''; // Show button when validated } else { validatedData = null; showValidationMessage(validationMsg, validation.message, 'error'); previewContainer.style.display = 'none'; importBtn.disabled = true; importBtn.style.display = 'none'; // Hide button on validation failure } } catch (err) { validatedData = null; showValidationMessage(validationMsg, `Invalid JSON: ${err.message}`, 'error'); previewContainer.style.display = 'none'; importBtn.disabled = true; importBtn.style.display = 'none'; // Hide button on parse error } }; importBtn.onclick = async () => { if (!validatedData) { showValidationMessage(validationMsg, 'Please validate the data first.', 'error'); return; } importBtn.disabled = true; validateBtn.disabled = true; clearBtn.disabled = true; summaryContainer.style.display = 'none'; // Show progress container immediately at 0/X items progressContainer.style.display = 'block'; showImportProgress(progressContainer, 0, validatedData.length, 'Starting import...'); try { const results = await importWatchlistData(validatedData, progressContainer); showImportSummary(summaryContainer, results); summaryContainer.style.display = 'block'; // Refresh watchlist await initWatchlistTab(); renderWatchlistContent(); } catch (err) { ERR('Import failed:', err); showValidationMessage(validationMsg, `Import failed: ${err.message}`, 'error'); } finally { importBtn.disabled = false; validateBtn.disabled = false; clearBtn.disabled = false; } }; clearBtn.onclick = async () => { if (confirm('Are you sure you want to clear your entire watchlist? This action cannot be undone.')) { if (confirm('This will remove ALL items from your watchlist. Are you absolutely sure?')) { clearBtn.disabled = true; clearBtn.textContent = 'Clearing...'; try { await clearWatchlist(); // Close modal after clearing window.ModalSystem.close('import-export-modal'); } catch (err) { ERR('Failed to clear watchlist:', err); alert('Failed to clear watchlist. Please try again.'); } finally { clearBtn.disabled = false; clearBtn.textContent = 'Clear Watchlist'; } } } }; } const PLAYLIST_SYNC_DEFAULT_NAME = '| Watchlist |'; const PLAYLIST_SYNC_CHUNK_SIZE = 75; function renderPlaylistSyncTab(container, state = { playlists: null, isLoading: false }) { container.innerHTML = ''; const description = document.createElement('p'); description.style.cssText = 'color: rgba(255, 255, 255, 0.7); margin-bottom: 20px; font-size: 14px; line-height: 1.5;'; description.textContent = 'Sync your watchlist directly to a Jellyfin playlist. We will overwrite the playlist with the items currently on your watchlist.'; container.appendChild(description); const selectGroup = document.createElement('div'); selectGroup.style.cssText = 'display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px;'; const selectLabel = document.createElement('label'); selectLabel.textContent = 'Choose Playlist'; selectLabel.style.cssText = 'font-weight: 600; color: #fff;'; selectGroup.appendChild(selectLabel); const playlistSelect = document.createElement('select'); playlistSelect.id = 'playlist-sync-select'; playlistSelect.style.cssText = 'padding: 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.3); color: #fff;'; playlistSelect.disabled = true; selectGroup.appendChild(playlistSelect); const selectHint = document.createElement('div'); selectHint.style.cssText = 'font-size: 12px; color: rgba(255, 255, 255, 0.6);'; selectHint.textContent = 'Default: | Watchlist |. Choose "New..." to create a fresh playlist.'; selectGroup.appendChild(selectHint); container.appendChild(selectGroup); const buttonRow = document.createElement('div'); buttonRow.style.cssText = 'display: flex; gap: 10px; align-items: center;'; const syncBtn = document.createElement('button'); syncBtn.className = 'emby-button raised button-submit'; syncBtn.textContent = 'Sync Playlist'; syncBtn.disabled = true; const refreshBtn = document.createElement('button'); refreshBtn.className = 'emby-button raised'; refreshBtn.textContent = 'Refresh List'; buttonRow.appendChild(syncBtn); buttonRow.appendChild(refreshBtn); container.appendChild(buttonRow); const statusMsg = document.createElement('div'); statusMsg.style.cssText = 'margin-top: 15px; font-size: 13px; display: none;'; container.appendChild(statusMsg); const loadingText = document.createElement('div'); loadingText.style.cssText = 'margin-top: 10px; font-size: 13px; color: rgba(255, 255, 255, 0.6);'; container.appendChild(loadingText); const setStatus = (message, type = 'info') => { if (!message) { statusMsg.style.display = 'none'; statusMsg.textContent = ''; return; } statusMsg.style.display = 'block'; statusMsg.textContent = message; if (type === 'error') { statusMsg.style.color = '#ff6b6b'; } else if (type === 'success') { statusMsg.style.color = '#4caf50'; } else { statusMsg.style.color = 'rgba(255, 255, 255, 0.7)'; } }; const setLoading = (isLoading, message = 'Loading playlists...') => { state.isLoading = isLoading; playlistSelect.disabled = isLoading; refreshBtn.disabled = isLoading; syncBtn.disabled = isLoading || !playlistSelect.value; loadingText.textContent = isLoading ? message : ''; }; const populateSelect = (playlists = []) => { playlistSelect.innerHTML = ''; const newOption = document.createElement('option'); newOption.value = '__new__'; newOption.textContent = 'New...'; playlistSelect.appendChild(newOption); playlists .sort((a, b) => a.Name.localeCompare(b.Name)) .forEach(playlist => { const option = document.createElement('option'); option.value = playlist.Id; option.textContent = `${playlist.Name} (${playlist.ChildCount || 0})`; playlistSelect.appendChild(option); }); const defaultPlaylist = playlists.find(pl => pl.Name === PLAYLIST_SYNC_DEFAULT_NAME); if (defaultPlaylist) { playlistSelect.value = defaultPlaylist.Id; } else { playlistSelect.value = '__new__'; } playlistSelect.disabled = false; syncBtn.disabled = false; }; const loadPlaylists = async (force = false) => { if (state.isLoading) return; try { setStatus(''); setLoading(true); if (!force && state.playlists && state.playlists.length) { populateSelect(state.playlists); return; } const playlists = await fetchUserPlaylistsForSync(); state.playlists = playlists; if (!playlists.length) { setStatus('No playlists found. Use the "New..." option to create one from your watchlist.', 'info'); } populateSelect(playlists); } catch (err) { ERR('Failed to load playlists:', err); setStatus(`Failed to load playlists: ${err.message}`, 'error'); playlistSelect.innerHTML = ''; const loadingOption = document.createElement('option'); loadingOption.textContent = 'Unable to load playlists'; playlistSelect.appendChild(loadingOption); playlistSelect.disabled = true; syncBtn.disabled = true; } finally { setLoading(false); } }; const getSelectedPlaylist = () => { if (playlistSelect.value === '__new__') { return { isNew: true, playlist: null }; } const playlist = state.playlists?.find(pl => pl.Id === playlistSelect.value); return { isNew: false, playlist }; }; playlistSelect.addEventListener('change', () => { setStatus(''); syncBtn.disabled = state.isLoading || !playlistSelect.value; }); refreshBtn.addEventListener('click', () => loadPlaylists(true)); syncBtn.addEventListener('click', () => { const selection = getSelectedPlaylist(); if (!selection.isNew && !selection.playlist) { setStatus('Please select a playlist or choose "New...".', 'error'); return; } openPlaylistSyncConfirmation({ isNew: selection.isNew, playlist: selection.playlist, onSuccess: (message) => { setStatus(message || 'Playlist synced successfully.', 'success'); loadPlaylists(true); } }); }); loadPlaylists(); } async function fetchUserPlaylistsForSync() { const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const url = `${serverUrl}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ChildCount`; const response = await fetch(url, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data.Items || []; } function openPlaylistSyncConfirmation({ isNew, playlist, onSuccess }) { if (!window.ModalSystem) { alert('Modal system not available'); return; } const modalId = 'playlist-sync-confirm-modal'; window.ModalSystem.close(modalId); const modalContent = document.createElement('div'); modalContent.style.cssText = 'display: flex; flex-direction: column; gap: 15px;'; const message = document.createElement('p'); message.style.cssText = 'color: rgba(255, 255, 255, 0.8); line-height: 1.5;'; let nameInput = null; let title = 'Sync Playlist'; if (isNew) { title = 'Create Playlist From Watchlist'; message.textContent = 'A new playlist will be created containing the items currently on your watchlist.'; nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'emby-input'; nameInput.placeholder = 'Playlist name'; nameInput.value = PLAYLIST_SYNC_DEFAULT_NAME; nameInput.style.cssText = 'padding: 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.3); color: #fff;'; modalContent.appendChild(message); modalContent.appendChild(nameInput); } else { title = 'Sync Existing Playlist'; message.textContent = 'This will set the items in the playlist to be the items in your watchlist. Any items on this playlist which are not in your watchlist will be removed.'; const playlistInfo = document.createElement('div'); playlistInfo.style.cssText = 'padding: 10px; border-radius: 4px; background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.9);'; playlistInfo.textContent = `Playlist: ${playlist?.Name || 'Unknown'}`; modalContent.appendChild(message); modalContent.appendChild(playlistInfo); } const modal = window.ModalSystem.create({ id: modalId, title, content: modalContent, closeOnBackdrop: true, closeOnEscape: true, footer: ` `, onOpen: (modalInstance) => { const confirmBtn = modalInstance.dialogFooter?.querySelector('#playlistSyncConfirmBtn'); const cancelBtn = modalInstance.dialogFooter?.querySelector('#playlistSyncCancelBtn'); const setError = (message) => { if (message) { const errorMsg = document.createElement('div'); errorMsg.style.cssText = 'color: #ff6b6b; font-size: 13px; display: none;'; modalContent.appendChild(errorMsg); errorMsg.textContent = message; errorMsg.style.display = 'block'; } }; if (cancelBtn) { cancelBtn.addEventListener('click', () => modalInstance.close()); } if (confirmBtn) { confirmBtn.addEventListener('click', async () => { try { setError(''); confirmBtn.disabled = true; confirmBtn.querySelector('span').textContent = 'Syncing...'; const playlistName = isNew ? (nameInput?.value || '').trim() : playlist?.Name; if (isNew && !playlistName) { setError('Please provide a playlist name.'); confirmBtn.disabled = false; confirmBtn.querySelector('span').textContent = 'Confirm'; return; } await syncWatchlistToPlaylist({ isNew, playlistId: playlist?.Id, playlistName }); modalInstance.close(); if (typeof onSuccess === 'function') { onSuccess(`Playlist "${playlistName}" now matches your watchlist.`); } showPlaylistSyncToast(`Playlist "${playlistName}" synced with your watchlist.`); } catch (err) { ERR('Playlist sync failed:', err); setError(err.message || 'Failed to sync playlist.'); } finally { if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.querySelector('span').textContent = 'Confirm'; } } }); } } }); return modal; } function showPlaylistSyncToast(message) { if (window.KefinTweaksToaster && typeof window.KefinTweaksToaster.toast === 'function') { window.KefinTweaksToaster.toast(message); } else { LOG(message); } } function buildPlaylistSyncItems(items = []) { if (!Array.isArray(items)) { return []; } const seriesIds = new Set(); const seasonIds = new Set(); items.forEach(item => { if (!item || !item.Id) return; if (item.Type === 'Series') { seriesIds.add(item.Id); } else if (item.Type === 'Season') { seasonIds.add(item.Id); } }); const seenIds = new Set(); const filtered = []; items.forEach(item => { if (!item || !item.Id) { return; } if (item.Type === 'Episode') { const parentSeasonId = item.ParentId || item.SeasonId; if ((parentSeasonId && seasonIds.has(parentSeasonId)) || (item.SeriesId && seriesIds.has(item.SeriesId))) { return; } } if (seenIds.has(item.Id)) { return; } filtered.push(item); seenIds.add(item.Id); }); return filtered; } async function syncWatchlistToPlaylist({ isNew, playlistId, playlistName }) { const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); if (!window.apiHelper || typeof window.apiHelper.getWatchlistItems !== 'function') { throw new Error('apiHelper is not available.'); } const watchlistItems = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode', Fields: 'ProviderIds' }, false); const itemsToSync = buildPlaylistSyncItems(watchlistItems.Items || []); if (!itemsToSync.length) { throw new Error('No eligible watchlist items to sync.'); } const watchlistIds = itemsToSync.map(item => item.Id); if (!watchlistIds.length) { throw new Error('Your watchlist is empty.'); } if (isNew) { await createPlaylistFromWatchlist({ serverUrl, token, userId, name: playlistName, itemIds: watchlistIds }); return; } if (!playlistId) { throw new Error('Playlist ID is required.'); } await replacePlaylistItemsWithWatchlist({ serverUrl, token, playlistId, itemIds: watchlistIds }); } async function createPlaylistFromWatchlist({ serverUrl, token, userId, name, itemIds }) { const params = new URLSearchParams({ Name: name, UserId: userId }); const response = await fetch(`${serverUrl}/Playlists?${params.toString()}`, { method: 'POST', headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`Failed to create playlist (${response.status})`); } const playlist = await response.json(); const playlistId = playlist?.Id; if (!playlistId) { throw new Error('Playlist created but no ID returned.'); } if (itemIds.length) { await addItemsToPlaylist({ serverUrl, token, playlistId, itemIds }); } } async function replacePlaylistItemsWithWatchlist({ serverUrl, token, playlistId, itemIds }) { // Remove existing entries const existingItemsResponse = await fetch(`${serverUrl}/Playlists/${playlistId}/Items`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!existingItemsResponse.ok) { throw new Error(`Failed to fetch existing playlist items (${existingItemsResponse.status})`); } const existingData = await existingItemsResponse.json(); const entryIds = (existingData.Items || []) .map(item => item.PlaylistItemId) .filter(Boolean); for (const chunk of chunkArray(entryIds, PLAYLIST_SYNC_CHUNK_SIZE)) { const params = new URLSearchParams({ EntryIds: chunk.join(',') }); const deleteResponse = await fetch(`${serverUrl}/Playlists/${playlistId}/Items?${params.toString()}`, { method: 'DELETE', headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!deleteResponse.ok) { throw new Error(`Failed to clear playlist items (${deleteResponse.status})`); } } await addItemsToPlaylist({ serverUrl, token, playlistId, itemIds }); } async function addItemsToPlaylist({ serverUrl, token, playlistId, itemIds }) { if (!itemIds.length) return; for (const chunk of chunkArray(itemIds, PLAYLIST_SYNC_CHUNK_SIZE)) { const params = new URLSearchParams({ Ids: chunk.join(',') }); const response = await fetch(`${serverUrl}/Playlists/${playlistId}/Items?${params.toString()}`, { method: 'POST', headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`Failed to add items to playlist (${response.status})`); } } } function chunkArray(array, size) { const chunks = []; for (let i = 0; i < array.length; i += size) { chunks.push(array.slice(i, i + size)); } return chunks; } // Show validation message function showValidationMessage(container, message, type) { container.style.display = 'block'; container.textContent = message; container.style.background = type === 'success' ? 'rgba(76, 175, 80, 0.2)' : 'rgba(255, 0, 0, 0.2)'; container.style.border = type === 'success' ? '1px solid rgba(76, 175, 80, 0.4)' : '1px solid rgba(255, 0, 0, 0.4)'; container.style.color = type === 'success' ? '#4caf50' : '#ff6b6b'; } // Show preview function showPreview(container, data) { container.innerHTML = ''; container.style.display = 'block'; const previewTitle = document.createElement('div'); previewTitle.style.cssText = 'font-weight: 600; margin-bottom: 8px; color: #fff;'; previewTitle.textContent = `Preview: ${data.length} item(s) to import`; container.appendChild(previewTitle); const previewList = document.createElement('div'); previewList.style.cssText = 'max-height: 150px; overflow-y: auto;'; data.slice(0, 10).forEach((item, index) => { const itemDiv = document.createElement('div'); itemDiv.style.cssText = 'padding: 6px; margin-bottom: 4px; background: rgba(255, 255, 255, 0.05); border-radius: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.8);'; const name = item.Name || item.name || 'Unknown'; const providers = []; if (item.Imdb || item.imdb) providers.push('IMDb'); if (item.Tmdb || item.tmdb) providers.push('TMDB'); if (item.Tvdb || item.tvdb) providers.push('TVDB'); itemDiv.textContent = `${index + 1}. ${name} (${providers.join(', ')})`; previewList.appendChild(itemDiv); }); if (data.length > 10) { const moreDiv = document.createElement('div'); moreDiv.style.cssText = 'padding: 6px; font-size: 12px; color: rgba(255, 255, 255, 0.6); font-style: italic;'; moreDiv.textContent = `... and ${data.length - 10} more item(s)`; previewList.appendChild(moreDiv); } container.appendChild(previewList); } // Show import progress function showImportProgress(container, current, total, itemName) { container.innerHTML = `
Importing: ${current} / ${total}
${itemName || ''}
`; } // Show import summary function showImportSummary(container, results) { container.innerHTML = ''; container.style.display = 'block'; container.style.background = 'rgba(255, 255, 255, 0.05)'; container.style.border = '1px solid rgba(255, 255, 255, 0.1)'; container.style.padding = '12px'; container.style.borderRadius = '4px'; const summaryTitle = document.createElement('div'); summaryTitle.style.cssText = 'font-weight: 600; margin-bottom: 8px; color: #fff; font-size: 14px;'; summaryTitle.textContent = 'Import Summary'; container.appendChild(summaryTitle); const summaryList = document.createElement('div'); summaryList.style.cssText = 'display: flex; flex-direction: column; gap: 4px; font-size: 13px;'; const successDiv = document.createElement('div'); successDiv.style.color = '#4caf50'; successDiv.textContent = `✓ Successfully imported: ${results.imported} item(s)`; summaryList.appendChild(successDiv); if (results.skipped > 0) { const skippedDiv = document.createElement('div'); skippedDiv.style.color = 'rgba(255, 255, 255, 0.7)'; skippedDiv.textContent = `⊘ Already in watchlist: ${results.skipped} item(s)`; summaryList.appendChild(skippedDiv); } if (results.notFound > 0) { const notFoundDiv = document.createElement('div'); notFoundDiv.style.color = '#ff6b6b'; notFoundDiv.textContent = `✗ Not found in library: ${results.notFound} item(s)`; summaryList.appendChild(notFoundDiv); } if (results.errors > 0) { const errorsDiv = document.createElement('div'); errorsDiv.style.color = '#ff6b6b'; errorsDiv.textContent = `✗ Errors: ${results.errors} item(s)`; summaryList.appendChild(errorsDiv); } container.appendChild(summaryList); } // Export watchlist data from server async function exportWatchlistData() { const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const exportData = []; // Fetch all watchlist items from server (not cache) const types = [ { type: 'Movie', section: 'movies' }, { type: 'Series', section: 'series' }, { type: 'Season', section: 'seasons' }, { type: 'Episode', section: 'episodes' } ]; const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode', Fields: 'ProviderIds,SeriesName,ParentId' }, false); const items = watchlistData.Items; for (const item of items) { try { const exportItem = { status: true, Name: item.Name, Type: item.Type // Add Type field for efficient import }; // Add provider IDs (only if they exist) if (item.ProviderIds) { if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb; if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb; if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb; } // For episodes, add SeriesName and SeasonName if (item.Type === 'Episode') { if (item.SeriesName) { exportItem.SeriesName = item.SeriesName; } // Get SeasonName from parent season if (item.ParentId) { try { const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`; const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const seasonData = await seasonRes.json(); if (seasonData.Name) { exportItem.SeasonName = seasonData.Name; } } catch (err) { WARN('Failed to fetch season name for episode:', err); } } } // For seasons, add SeriesName if (item.Type === 'Season') { if (item.SeriesName) { exportItem.SeriesName = item.SeriesName; } } // Only add if at least one provider ID exists if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) { exportData.push(exportItem); } } catch (err) { ERR(`Failed to export item:`, err); } } return exportData; } // Validate import data function validateImportData(data) { if (!Array.isArray(data)) { return { valid: false, message: 'Data must be an array of items.' }; } if (data.length === 0) { return { valid: false, message: 'No items to import.' }; } const errors = []; const providerIdMap = new Map(); // Track provider IDs for duplicate detection for (let i = 0; i < data.length; i++) { const item = data[i]; const itemNum = i + 1; // Check if item is an object if (typeof item !== 'object' || item === null) { errors.push(`Item ${itemNum}: Must be an object.`); continue; } // Check status field if (item.status !== undefined && typeof item.status !== 'boolean') { errors.push(`Item ${itemNum}: 'status' must be a boolean.`); } // Check Type field (required) const itemType = item.Type || item.type; if (!itemType) { errors.push(`Item ${itemNum}: 'Type' field is required (Movie, Series, Season, or Episode).`); continue; } if (!['Movie', 'Series', 'Season', 'Episode'].includes(itemType)) { errors.push(`Item ${itemNum}: 'Type' must be one of: Movie, Series, Season, Episode.`); continue; } // Check for at least one provider ID const imdb = item.Imdb || item.imdb; const tmdb = item.Tmdb || item.tmdb; const tvdb = item.Tvdb || item.tvdb; if (!imdb && !tmdb && !tvdb) { errors.push(`Item ${itemNum}: Must have at least one provider ID (Imdb, Tmdb, or Tvdb).`); continue; } // Check for duplicate provider IDs (include Type in key to allow same ID for different types) const providerKeys = []; if (imdb) providerKeys.push(`Imdb:${imdb}:${itemType}`); if (tmdb) providerKeys.push(`Tmdb:${tmdb}:${itemType}`); if (tvdb) providerKeys.push(`Tvdb:${tvdb}:${itemType}`); for (const key of providerKeys) { if (providerIdMap.has(key)) { const existingItem = providerIdMap.get(key); errors.push(`Item ${itemNum}: Duplicate provider ID '${key}' found (also in item ${existingItem}).`); } else { providerIdMap.set(key, itemNum); } } } if (errors.length > 0) { return { valid: false, message: `Validation failed:\n${errors.slice(0, 10).join('\n')}${errors.length > 10 ? `\n... and ${errors.length - 10} more error(s).` : ''}` }; } return { valid: true, message: `Validation successful: ${data.length} item(s) ready to import.` }; } // Import watchlist data async function importWatchlistData(data, progressContainer) { const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const results = { imported: 0, skipped: 0, notFound: 0, errors: 0 }; // Analyze import data to determine which types are used const usedTypes = new Set(); for (const item of data) { const itemType = item.Type || item.type; if (itemType) usedTypes.add(itemType); } // Build query parameters const includeTypes = Array.from(usedTypes).join(','); const queryParams = new URLSearchParams({ UserId: userId, Recursive: 'true', IncludeItemTypes: includeTypes, Fields: 'ProviderIds,Type' }); // Fetch all matching items in bulk let allLibraryItems = []; try { const url = `${serverUrl}/Items?${queryParams.toString()}`; const res = await fetch(url, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const libraryData = await res.json(); allLibraryItems = libraryData.Items || []; LOG(`Fetched ${allLibraryItems.length} items from library matching import criteria`); } catch (err) { ERR('Failed to fetch library items:', err); throw new Error('Failed to fetch library items. Please try again.'); } // Create a lookup map: providerId -> item const libraryMap = new Map(); for (const item of allLibraryItems) { if (item.ProviderIds) { if (item.ProviderIds.Imdb) { const key = `Imdb:${item.ProviderIds.Imdb}:${item.Type}`; libraryMap.set(key, item); } if (item.ProviderIds.Tmdb) { const key = `Tmdb:${item.ProviderIds.Tmdb}:${item.Type}`; libraryMap.set(key, item); } if (item.ProviderIds.Tvdb) { const key = `Tvdb:${item.ProviderIds.Tvdb}:${item.Type}`; libraryMap.set(key, item); } } } // Get all current watchlist items to check for duplicates const currentWatchlist = new Set(); const watchlistTypes = ['Movie', 'Series', 'Season', 'Episode']; for (const type of watchlistTypes) { try { const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=ProviderIds`; const res = await fetch(url, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const watchlistData = await res.json(); const items = watchlistData.Items || []; for (const item of items) { if (item.ProviderIds) { if (item.ProviderIds.Imdb) currentWatchlist.add(`Imdb:${item.ProviderIds.Imdb}:${type}`); if (item.ProviderIds.Tmdb) currentWatchlist.add(`Tmdb:${item.ProviderIds.Tmdb}:${type}`); if (item.ProviderIds.Tvdb) currentWatchlist.add(`Tvdb:${item.ProviderIds.Tvdb}:${type}`); } } } catch (err) { WARN(`Failed to fetch current watchlist for ${type}:`, err); } } // Process each import item for (let i = 0; i < data.length; i++) { const importItem = data[i]; const itemName = importItem.Name || importItem.name || `Item ${i + 1}`; // Update progress before processing item showImportProgress(progressContainer, i + 1, data.length, itemName); // Allow UI to update before processing await new Promise(resolve => requestAnimationFrame(resolve)); try { // Get provider IDs and type const imdb = importItem.Imdb || importItem.imdb; const tmdb = importItem.Tmdb || importItem.tmdb; const tvdb = importItem.Tvdb || importItem.tvdb; const itemType = importItem.Type || importItem.type; // Find matching item in library using provider IDs and type let foundItem = null; const searchKeys = []; if (imdb) searchKeys.push(`Imdb:${imdb}:${itemType}`); if (tmdb) searchKeys.push(`Tmdb:${tmdb}:${itemType}`); if (tvdb) searchKeys.push(`Tvdb:${tvdb}:${itemType}`); for (const key of searchKeys) { if (libraryMap.has(key)) { foundItem = libraryMap.get(key); break; // Use first match } } if (!foundItem) { results.notFound++; // Update progress after processing await new Promise(resolve => requestAnimationFrame(resolve)); continue; } // Check if already in watchlist let alreadyInWatchlist = false; for (const key of searchKeys) { if (currentWatchlist.has(key)) { alreadyInWatchlist = true; break; } } if (alreadyInWatchlist) { results.skipped++; // Update progress after processing await new Promise(resolve => requestAnimationFrame(resolve)); continue; } // Add to watchlist await apiClient.updateUserItemRating(userId, foundItem.Id, 'true'); results.imported++; // Update current watchlist set if (foundItem.ProviderIds) { if (foundItem.ProviderIds.Imdb) currentWatchlist.add(`Imdb:${foundItem.ProviderIds.Imdb}:${foundItem.Type}`); if (foundItem.ProviderIds.Tmdb) currentWatchlist.add(`Tmdb:${foundItem.ProviderIds.Tmdb}:${foundItem.Type}`); if (foundItem.ProviderIds.Tvdb) currentWatchlist.add(`Tvdb:${foundItem.ProviderIds.Tvdb}:${foundItem.Type}`); } // Update progress after processing await new Promise(resolve => requestAnimationFrame(resolve)); } catch (err) { ERR(`Error importing item ${i + 1}:`, err); results.errors++; // Update progress after error await new Promise(resolve => requestAnimationFrame(resolve)); } } return results; } // Clear entire watchlist async function clearWatchlist() { const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const types = ['Movie', 'Series', 'Season', 'Episode']; let cleared = 0; for (const type of types) { try { const data = await window.apiHelper.getItems({ Filters: 'Likes', IncludeItemTypes: type, Recursive: true, Fields: 'Id' }, false); const items = data.Items || []; for (const item of items) { try { await apiClient.updateUserItemRating(userId, item.Id, 'false'); cleared++; } catch (err) { ERR(`Failed to remove item ${item.Id}:`, err); } } } catch (err) { ERR(`Failed to fetch ${type} items for clearing:`, err); } } // Clear query cache + in-memory index await invalidateAllWatchlistQueries(); resetWatchlistItemsByType(); watchlistLikedIds.clear(); watchlistLikedIdsLoaded = true; writeLikedIdsToStorage(); syncOverlayWatchlistButtons(); // Refresh watchlist await initWatchlistTab(true); alert(`Watchlist cleared: ${cleared} item(s) removed.`); } /** * Membership changed: update liked-Id cache + invalidate Likes query URL. * Do not full-rebuild the Watchlist tab (avoids scroll jump / stale re-paint races). * Cards stay as ghosts until next visit; cardBuilder owns overlay button UI via UserDataChanged. */ async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) { try { LOG(`Watchlist membership changed for ${itemType || 'unknown'} ${itemId} (added=${isAdded}); invalidating query cache`); setLikedIdInCache(itemId, isAdded === true); patchWatchlistItemsByTypeMembership(itemId, itemType, isAdded === true); if (itemType && isWatchlistSupportedType(itemType)) { await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType }); } else { await invalidateAllWatchlistQueries(); } tabStates.watchlist.isDataFetched = false; } catch (err) { ERR('Error refreshing watchlist cache after toggle:', err); } } /** * Sync liked-Id cache + invalidate progressive Likes queries from UserData.Likes. * Idempotent for the Set when cardBuilder already applied Likes; always invalidate * so a remote/Library like is visible on the next Watchlist visit. */ async function handleItemWatchlistChange(itemId, isLiked, itemType) { if (!itemId || typeof isLiked !== 'boolean') return; if (isItemInLikedIdCache(itemId) !== isLiked) { setLikedIdInCache(itemId, isLiked); } // Keep header counts aligned even if liked-Id Set was already updated by cardBuilder patchWatchlistItemsByTypeMembership(itemId, itemType, isLiked); try { if (itemType && isWatchlistSupportedType(itemType)) { await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType }); } else { await invalidateAllWatchlistQueries(); } tabStates.watchlist.isDataFetched = false; } catch (err) { ERR('Error invalidating watchlist after Likes change:', err); } } // Initialize monitoring to remove watched items from watchlist (playback + manual) async function initializePlaybackMonitoring() { if (playbackMonitorInitialized) { return; } await window.userHelper.waitForLogin(); function startWatchlistUserDataListener() { if (playbackMonitorInitialized) { return; } if (window.websocketHelper?.listen) { window.websocketHelper.listen('UserDataChanged', (msg) => { const entries = msg?.Data?.UserDataList; if (!Array.isArray(entries) || !entries.length) return; for (const userData of entries) { if (!userData?.ItemId) continue; LOG(`Detected UserDataChanged for item: ${userData.ItemId}, Played: ${userData.Played}, Likes: ${userData.Likes}`); (async () => { await handleItemWatchlistChange(userData.ItemId, userData.Likes); await handleItemWatchedStatusChange(userData.ItemId, userData.Played, userData.Likes); })().catch(err => { ERR('Error handling UserDataChanged event:', err); }); } }); playbackMonitorInitialized = true; LOG('Playback and watch status monitoring initialized via WebSocket'); return; } setTimeout(startWatchlistUserDataListener, 1000); } startWatchlistUserDataListener(); } let watchedItems = []; async function getWatchedItem(itemId) { const watchedItem = watchedItems.find(item => item.Id === itemId); if (watchedItem) { return watchedItem; } const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId); if (!item) { LOG('Could not fetch item data for:', itemId); return; } watchedItems.push(item); return item; } // Shared handler for when items' watched status changes (played or unplayed) async function handleItemWatchedStatusChange(itemId, isPlayed, isWatchlisted) { try { LOG('Item watched status changed:', itemId); // Get the item data to check current played status const item = await getWatchedItem(itemId); // Only handle movies, series, seasons, and episodes if (item.Type !== 'Movie' && item.Type !== 'Series' && item.Type !== 'Season' && item.Type !== 'Episode') { return; } LOG(`Item is now ${isPlayed ? 'played' : 'unplayed'}:`, item.Name); if (isPlayed) { if (isWatchlisted) { // Item is now played - remove from watchlist and update caches LOG('Item is now played and watchlisted, removing from watchlist:', item.Name); await removeItemFromWatchlist(itemId, item.Type); } // Update relevant caches based on item type if (item.Type === 'Episode') { // Update series progress cache for episodes if (item.SeriesId) { if (progressCache.allDataLoaded) { LOG('Updating series progress cache for episode:', item.Name); await updateEpisodeWatchedStatusInCache(item.SeriesId, itemId, true); } else { LOG('Progress cache not fully loaded, updating localStorage only for episode:', item.Name); await updateEpisodeInLocalStorage(item); } } // Check parent Season and Series await checkAndRemoveParentItems(item); } else if (item.Type === 'Movie') { // Update movie history cache for movies if (movieCache.allDataLoaded) { LOG('Updating movie history cache for movie:', item.Name); await updateMovieHistoryCache(item); } else { LOG('Movie cache not fully loaded, updating localStorage only for movie:', item.Name); await updateMovieInLocalStorage(item); } } // Extract item ID from current URL const currentUrl = window.location.hash; const urlMatch = currentUrl.match(/[?&]id=([^&]+)/); const currentItemId = urlMatch ? urlMatch[1] : null; // Hide the watchlist button if we are on the item detail page of the item that was just watched if (currentItemId === itemId) { const watchlistIcon = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon'); if (watchlistIcon) { watchlistIcon.style.display = "none"; LOG('Hidden watchlist button on item detail page of watched item'); } } // Check for any cards on the page for the item that was just watched and hide the watchlist button if found const itemCards = document.querySelectorAll('.card[data-id="' + itemId + '"]'); if (itemCards.length > 0) { for (const card of itemCards) { const watchlistIcon = card.querySelector('.watchlist-button'); if (watchlistIcon) { watchlistIcon.dataset.active = "false"; LOG('Hidden watchlist button on card for watched item'); } } } LOG('Successfully removed played item from watchlist and updated caches:', item.Name); } else { // Item is now unplayed - update caches to reflect unwatched status LOG('Item is now unplayed, updating caches:', item.Name); // Update relevant caches based on item type if (item.Type === 'Episode') { // Update series progress cache for episodes (mark as unwatched) if (item.SeriesId) { if (progressCache.allDataLoaded) { LOG('Updating series progress cache for episode (unwatched):', item.Name); await updateEpisodeWatchedStatusInCache(item.SeriesId, itemId, false); } else { LOG('Progress cache not fully loaded, updating localStorage only for episode (unwatched):', item.Name); await updateEpisodeInLocalStorageUnwatched(item); } } } else if (item.Type === 'Movie') { // Remove from movie history cache for movies if (movieCache.allDataLoaded) { LOG('Removing movie from history cache (unwatched):', item.Name); await removeMovieFromHistoryCache(item); } else { LOG('Movie cache not fully loaded, updating localStorage only for movie (unwatched):', item.Name); await removeMovieFromLocalStorage(item); } } // Unhide the watchlist button if we are on the item detail page of the item that was just watched const watchlistIcon = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon'); if (watchlistIcon) { watchlistIcon.style.display = "block"; LOG('Shown watchlist button on item detail page of unwatched item'); } LOG('Successfully updated caches for unplayed item:', item.Name); } } catch (err) { ERR('Error handling item watched status change:', err); } } // Update episode in localStorage when progress cache is not fully loaded async function updateEpisodeInLocalStorage(episode) { try { const cachedProgress = localStorageCache.get('progress_v2'); if (!cachedProgress) { LOG('No progress data in localStorage to update'); return; } const seriesIndex = cachedProgress.findIndex(p => p.series.Id === episode.SeriesId); if (seriesIndex === -1) { LOG('Series not found in localStorage progress data:', episode.SeriesId); // Fetch series data from API const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { // Fetch the series item const seriesUrl = `${serverUrl}/Items/${seriesId}?UserId=${userId}&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`; const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const series = await seriesRes.json(); if (!series || !series.Id) { LOG(`Failed to fetch series data for seriesId: ${seriesId}`); return; } // Fetch and create progress data for this series cachedProgress = await fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token); if (!cachedProgress) { LOG(`Failed to create progress data for series: ${seriesId}`); return; } LOG(`Successfully added series ${series.Name} to cache`); } catch (err) { ERR(`Failed to fetch and add series ${seriesId} to cache:`, err); return; } } const seriesProgress = cachedProgress[seriesIndex]; const season = String(episode.ParentIndexNumber); const episodeIndex = episode.IndexNumber - 1; // Convert to 0-based index // Update binaryProgress for the specific episode if (seriesProgress.binaryProgress && seriesProgress.binaryProgress[season]) { const seasonProgress = seriesProgress.binaryProgress[season]; if (episodeIndex >= 0 && episodeIndex < seasonProgress.length) { // Mark episode as watched (set to '1') seriesProgress.binaryProgress[season] = seasonProgress.substring(0, episodeIndex) + '1' + seasonProgress.substring(episodeIndex + 1); LOG(`Updated episode ${episode.IndexNumber} in season ${season} as watched in localStorage`); // Save back to localStorage localStorageCache.set('progress_v2', cachedProgress); } } } catch (err) { ERR('Error updating episode in localStorage:', err); } } // Update movie in localStorage when movie cache is not fully loaded async function updateMovieInLocalStorage(movie) { try { const cachedMovies = localStorageCache.get('movies'); if (!cachedMovies) { LOG('No movie data in localStorage to update'); return; } // Check if movie already exists const existingIndex = cachedMovies.findIndex(m => m.Id === movie.Id); if (existingIndex !== -1) { // Update existing movie cachedMovies[existingIndex] = movie; LOG('Updated existing movie in localStorage:', movie.Name); } else { // Add new movie to beginning (most recent first) cachedMovies.unshift(movie); LOG('Added new movie to localStorage:', movie.Name); } // Save back to localStorage localStorageCache.set('movies', cachedMovies); } catch (err) { ERR('Error updating movie in localStorage:', err); } } // Update episode as unwatched in localStorage when progress cache is not fully loaded async function updateEpisodeInLocalStorageUnwatched(episode) { try { const cachedProgress = localStorageCache.get('progress_v2'); if (!cachedProgress) { LOG('No progress data in localStorage to update'); return; } const seriesIndex = cachedProgress.findIndex(p => p.series.Id === episode.SeriesId); if (seriesIndex === -1) { LOG('Series not found in localStorage progress data:', episode.SeriesId); return; } const seriesProgress = cachedProgress[seriesIndex]; const season = String(episode.ParentIndexNumber); const episodeIndex = episode.IndexNumber - 1; // Convert to 0-based index // Update binaryProgress for the specific episode if (seriesProgress.binaryProgress && seriesProgress.binaryProgress[season]) { const seasonProgress = seriesProgress.binaryProgress[season]; if (episodeIndex >= 0 && episodeIndex < seasonProgress.length) { // Mark episode as unwatched (set to '0') seriesProgress.binaryProgress[season] = seasonProgress.substring(0, episodeIndex) + '0' + seasonProgress.substring(episodeIndex + 1); LOG(`Updated episode ${episode.IndexNumber} in season ${season} as unwatched in localStorage`); // Save back to localStorage localStorageCache.set('progress_v2', cachedProgress); } } } catch (err) { ERR('Error updating episode as unwatched in localStorage:', err); } } // Remove movie from history cache when marked as unwatched async function removeMovieFromHistoryCache(movie) { try { // Find and remove movie from cache const existingIndex = movieCache.data.findIndex(cachedMovie => cachedMovie.Id === movie.Id); if (existingIndex !== -1) { movieCache.data.splice(existingIndex, 1); LOG('Removed movie from history cache:', movie.Name); // Update cache metadata movieCache.totalPages = Math.ceil(movieCache.data.length / movieCache.pageSize); // Update localStorage cache await localStorageCache.set('movies', movieCache.data); // Update filtered data if search is active if (movieCache.searchTerm) { updateMovieSearchResults(movieCache.searchTerm); } LOG('Movie removed from history cache successfully'); } else { LOG('Movie not found in history cache:', movie.Name); } } catch (err) { ERR('Error removing movie from history cache:', err); } } // Remove movie from localStorage when movie cache is not fully loaded async function removeMovieFromLocalStorage(movie) { try { const cachedMovies = localStorageCache.get('movies'); if (!cachedMovies) { LOG('No movie data in localStorage to update'); return; } // Find and remove movie const existingIndex = cachedMovies.findIndex(m => m.Id === movie.Id); if (existingIndex !== -1) { cachedMovies.splice(existingIndex, 1); LOG('Removed movie from localStorage:', movie.Name); // Save back to localStorage localStorageCache.set('movies', cachedMovies); } else { LOG('Movie not found in localStorage:', movie.Name); } } catch (err) { ERR('Error removing movie from localStorage:', err); } } // Update movie history cache when a movie is marked as played async function updateMovieHistoryCache(movie) { try { // Check if movie is already in cache const existingIndex = movieCache.data.findIndex(cachedMovie => cachedMovie.Id === movie.Id); if (existingIndex !== -1) { // Update existing movie in cache movieCache.data[existingIndex] = movie; LOG('Updated existing movie in history cache:', movie.Name); } else { // Add new movie to cache movieCache.data.unshift(movie); // Add to beginning (most recent first) LOG('Added new movie to history cache:', movie.Name); } // Update cache metadata movieCache.totalPages = Math.ceil(movieCache.data.length / movieCache.pageSize); movieCache.allDataLoaded = true; // Update localStorage cache await localStorageCache.set(`movies`, movieCache.data); // Update filtered data if search is active if (movieCache.searchTerm) { updateMovieSearchResults(movieCache.searchTerm); } LOG('Movie history cache updated successfully'); } catch (err) { ERR('Error updating movie history cache:', err); } } // Remove item from watchlist (API + cache + UI) async function removeItemFromWatchlist(itemId, itemType) { // Remove from watchlist via API await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, 'false'); // Remove from cache await updateWatchlistCacheOnToggle(itemId, itemType, false); // Update watchlist button on item detail page if user is viewing this item await updateWatchlistButtonOnDetailPage(); } // Check and remove parent Season/Series if they're played async function checkAndRemoveParentItems(episode) { try { // Check Season if (episode.SeasonId) { const seasonInWatchlist = await checkIfItemInWatchlist(episode.SeasonId); if (seasonInWatchlist) { const seasonItem = await ApiClient.getItem(ApiClient.getCurrentUserId(), episode.SeasonId); if (seasonItem && seasonItem.UserData && seasonItem.UserData.Played) { LOG('Season is now played, removing from watchlist:', seasonItem.Name); await removeItemFromWatchlist(episode.SeasonId, 'Season'); } } } // Check Series if (episode.SeriesId) { const seriesInWatchlist = await checkIfItemInWatchlist(episode.SeriesId); if (seriesInWatchlist) { const seriesItem = await ApiClient.getItem(ApiClient.getCurrentUserId(), episode.SeriesId); if (seriesItem && seriesItem.UserData && seriesItem.UserData.Played) { LOG('Series is now played, removing from watchlist:', seriesItem.Name); await removeItemFromWatchlist(episode.SeriesId, 'Series'); } } } } catch (err) { ERR('Error checking parent items:', err); } } // Sync watched status to watchlist - remove all played items async function syncWatchedStatusToWatchlist() { try { LOG('Syncing watched status to watchlist...'); let removedCount = 0; for (const { type } of WATCHLIST_SECTION_TYPES) { const items = watchlistItemsByType[type] || []; const playedItems = items.filter(item => item.UserData && item.UserData.Played); if (playedItems.length > 0) { LOG(`Found ${playedItems.length} played items in ${type} section`); for (const item of playedItems) { await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), item.Id, 'false'); setLikedIdInCache(item.Id, false); removedCount++; } } } if (removedCount > 0) { LOG(`Sync complete: removed ${removedCount} played items from watchlist; refreshing`); syncOverlayWatchlistButtons(); await invalidateAllWatchlistQueries(); await renderWatchlistProgressive(true); } else { LOG('Sync complete: no played items found in watchlist'); } } catch (err) { ERR('Error syncing watched status to watchlist:', err); } } // Update watchlist button on item detail page based on current item's watchlist status async function updateWatchlistButtonOnDetailPage() { try { // Check if we're on an item detail page const itemDetailPage = document.querySelector('#itemDetailPage:not(.hide)'); if (!itemDetailPage) { LOG('Not on item detail page, skipping watchlist button update'); return; } // Check if there's a watchlist button const watchlistButton = itemDetailPage.querySelector('.mainDetailButtons .watchlist-icon.detailButton'); if (!watchlistButton) { LOG('No watchlist button found on detail page, skipping update'); return; } // Extract item ID from current URL const currentUrl = window.location.hash; const urlMatch = currentUrl.match(/[?&]id=([^&]+)/); const currentItemId = urlMatch ? urlMatch[1] : null; if (!currentItemId) { LOG('Could not extract item ID from URL, skipping update'); return; } // Fetch current item data to get watchlist status const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), currentItemId); if (!item || !item.UserData) { LOG('Could not fetch item data, skipping watchlist button update'); return; } // Update button based on current watchlist status const isInWatchlist = item.UserData.Likes ?? false; watchlistButton.setAttribute('data-active', isInWatchlist.toString()); watchlistButton.title = isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist'; LOG(`Updated watchlist button on detail page for item ${currentItemId}: ${isInWatchlist ? 'in watchlist' : 'not in watchlist'}`); } catch (err) { ERR('Error updating watchlist button on detail page:', err); } } // Check if an item is in the user's watchlist via in-memory index or API async function checkIfItemInWatchlist(itemId) { try { for (const { type } of WATCHLIST_SECTION_TYPES) { if ((watchlistItemsByType[type] || []).some(item => item.Id === itemId)) { return true; } } const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId); if (item && item.UserData && item.UserData.Likes) { LOG(`Item ${itemId} is in watchlist (via API check)`); return true; } LOG(`Item ${itemId} is not in watchlist (via API check)`); return false; } catch (err) { ERR('Error checking if item is in watchlist:', err); return false; } } async function fetchWatchedMovies(useCache = true) { // Check if we're already fetching to prevent duplicate calls if (tabStates.history.isFetching) { LOG('Movie data fetch already in progress, skipping duplicate call'); return movieCache.data; // Return existing data or empty array } // Check if we have valid cached movie data if (useCache && moviePagination.isCacheValid() && movieCache.data.length > 0) { LOG('Using cached movie data'); return movieCache.data; } // Check localStorage cache if in-memory cache is empty if (useCache && movieCache.data.length === 0) { const cachedData = localStorageCache.get(`movies`); if (cachedData) { LOG('Using localStorage cache for movies'); moviePagination.updateCache(cachedData); return cachedData; } } // Set fetching flag to prevent duplicate calls tabStates.history.isFetching = true; LOG('Starting movie data fetch...'); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); // Fetch movies that have been played via apiHelper try { const data = await window.apiHelper.getItems({ IncludeItemTypes: 'Movie', Recursive: true, Filters: 'IsPlayed', Fields: 'UserData,ProviderIds,People', EnableImageTypes: 'Primary,Backdrop,Thumb', ImageTypeLimit: 1, SortBy: 'DatePlayed', SortOrder: 'Descending' }, false); const movies = data.Items || []; // Filter out movie duplicates const seenImdbIds = new Set(); const filteredMovies = movies.filter(movie => { if (!movie.ProviderIds || !movie.ProviderIds.Imdb) return true; if (seenImdbIds.has(movie.ProviderIds.Imdb)) return false; seenImdbIds.add(movie.ProviderIds.Imdb); return true; }); // Sort movies by most recently watched const sortedMovies = filteredMovies.sort((a, b) => { const dateA = a.UserData && a.UserData.LastPlayedDate ? new Date(a.UserData.LastPlayedDate) : new Date(0); const dateB = b.UserData && b.UserData.LastPlayedDate ? new Date(b.UserData.LastPlayedDate) : new Date(0); return dateB - dateA; // Most recent first }); // Update cache moviePagination.updateCache(sortedMovies); // Store in localStorage for next time (optimized) const optimizedMovies = optimizeMovieDataForStorage(sortedMovies); localStorageCache.set(`movies`, optimizedMovies); LOG('Stored optimized movie data in localStorage'); LOG(`Found and cached ${sortedMovies.length} watched movies`); return sortedMovies; } catch (err) { ERR("Failed to fetch watched movies:", err); return []; } finally { // Always clear the fetching flag, even on error tabStates.history.isFetching = false; LOG('Movie data fetch completed'); renderStatisticsContent(); } } function createMovieCard(movie) { const card = document.createElement('div'); card.className = 'movie-card'; // Get movie image URL const imageUrl = movie.ImageTags.Thumb && movie.ImageTags.Thumb ? `${ApiClient._serverAddress}/Items/${movie.Id}/Images/Thumb?maxHeight=400&maxWidth=400&tag=${movie.ImageTags.Thumb}` : movie.BackdropImageTags && movie.BackdropImageTags[0] ? `${ApiClient._serverAddress}/Items/${movie.Id}/Images/Backdrop?maxHeight=400&maxWidth=400&tag=${movie.BackdropImageTags[0]}` : movie.ImageTags.Primary ? `${ApiClient._serverAddress}/Items/${movie.Id}/Images/Primary?maxHeight=300&maxWidth=200&tag=${movie.ImageTags.Primary}` : ''; // Format watched date const watchedDate = movie.UserData && movie.UserData.LastPlayedDate ? formatLastWatchedDate(movie.UserData.LastPlayedDate) : 'Unknown date'; // Format runtime const runtime = movie.RunTimeTicks ? Math.round(movie.RunTimeTicks / 10000000 / 60) + ' min' : 'Unknown runtime'; // Format year const year = movie.ProductionYear || movie.PremiereDate ? new Date(movie.PremiereDate || movie.ProductionYear).getFullYear() : 'Unknown year'; // Check if movie is favorited const isFavorite = movie.UserData && movie.UserData.IsFavorite; const favoriteIcon = isFavorite ? 'favorite' : 'favorite_border'; const favoriteText = isFavorite ? 'Favorited' : 'Favorite'; const favoriteClass = isFavorite ? 'favorited' : ''; card.innerHTML = `
${imageUrl ? `${movie.Name}` : `
`}
Watched

${movie.Name}

${year}
${runtime}
Watched on ${watchedDate}
`; return card; } async function renderHistoryContent() { LOG('Rendering Movie History content'); const watchlistSection = getWatchlistSection(); const historyTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="history"]') : null; const paginatedContainer = historyTab ? historyTab.querySelector('.paginated-container') : null; const container = paginatedContainer ? paginatedContainer.querySelector('.movie-history-grid') : null; const emptyMessage = historyTab ? historyTab.querySelector('.movie-history-empty-message') : null; const movieCount = getElementByIdSafe('movie-count'); if (!container) { WARN("Movie history container not found"); return; } try { // Check if we're currently fetching data or data hasn't been fetched yet if (tabStates.history.isFetching || !tabStates.history.isDataFetched) { hideEmptyHistoryMessage(); container.innerHTML = '
Loading movie history...
'; return; } // Get current page and search terms from URL const params = getUrlParams(); const currentPage = (params.pageTab === 'history') ? (params.page || 1) : (movieCache.currentPage || 1); const movieSearchTerm = params.movieSearch || ''; // Get current sort order and apply sorting to movie data const currentSort = getCurrentMovieSortOrder(); const sortedMovies = applyMovieSorting(movieCache.data, currentSort); // Update pagination with sorted data moviePagination.updateCache(sortedMovies); // Get paginated movie data using the new pagination system const { pageData: movies, totalPages } = moviePagination.getCachedPage(currentPage); const searchInput = getElementByIdSafe('movie-search'); if (searchInput && params.pageTab === 'history') { searchInput.value = movieSearchTerm; } if (totalPages > 1) { renderPaginationControls(currentPage, totalPages, 'history'); } // Check if we should skip rendering if (shouldSkipRendering('history', currentPage, movieSearchTerm, container, movies)) { LOG(`Skipping history render - same page (${currentPage}), search ("${movieSearchTerm}"), and content`); // Still need to ensure pagination controls are rendered if (totalPages > 1) { renderPaginationControls(currentPage, totalPages, 'history'); } return; } // Restore search state if URL has search parameter if (movieSearchTerm && movieSearchTerm !== movieCache.searchTerm) { const searchInput = getElementByIdSafe('movie-search'); const clearBtn = getElementByIdSafe('movie-search-clear'); if (searchInput) { searchInput.value = movieSearchTerm; updateMovieSearchResults(movieSearchTerm); if (clearBtn) clearBtn.style.display = 'flex'; } } // Always use full, non-filtered movie data for stats const totalMovies = movieCache.data.length; // Update movie count if (movieCount) { movieCount.textContent = `${totalMovies} Watched`; } if (totalMovies === 0) { // Show empty message container.innerHTML = ''; if (emptyMessage) { emptyMessage.style.display = 'block'; } LOG('No watched movies found, showing empty state'); return; } // Hide empty message and clear container if (emptyMessage) { emptyMessage.style.display = 'none'; } container.innerHTML = ''; // Create movie cards movies.forEach(movie => { const movieCard = createMovieCard(movie); container.appendChild(movieCard); }); // Update cache currentPage movieCache.currentPage = currentPage; // Update tab state after successful render updateTabState('history', currentPage, movieSearchTerm); LOG(`Rendered ${movies.length} watched movies for page ${currentPage} of ${totalPages}`); } catch (err) { ERR('Error rendering movie history content:', err); // Clear container and show empty message on error container.innerHTML = ''; clearTabState('history'); if (emptyMessage) { emptyMessage.style.display = 'block'; } } } let statisticsRenderPromise = null; async function renderStatisticsContent() { // Coalesce concurrent calls (e.g. init + switchTab both firing) if (statisticsRenderPromise) { return statisticsRenderPromise; } statisticsRenderPromise = (async () => { LOG('Rendering Statistics content'); const watchlistSection = getWatchlistSection(); const statisticsTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="statistics"]') : null; if (!statisticsTab) return; try { statisticsTab.setAttribute('data-ready', 'false'); await Promise.all([ fetchAllProgressData(true), fetchWatchedMovies(true) ]); tabStates.progress.isDataFetched = true; tabStates.history.isDataFetched = true; // Reveal layout before Chart.js measures canvas size (hidden → 0-height blank charts) statisticsTab.setAttribute('data-ready', 'true'); await new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); }); if (window.KefinWatchlistStats && typeof window.KefinWatchlistStats.render === 'function') { window.KefinWatchlistStats.render(statisticsTab, { progress: progressCache.data, movies: movieCache.data }); if (typeof window.KefinWatchlistStats.resize === 'function') { window.KefinWatchlistStats.resize(); requestAnimationFrame(() => window.KefinWatchlistStats.resize()); } } else { WARN('KefinWatchlistStats unavailable; falling back to summary cards only'); await updateProgressStatistics(progressCache.data); } bindShowAllShowsButton(statisticsTab); LOG('Statistics content rendered successfully'); } catch (err) { ERR('Error rendering statistics content:', err); statisticsTab.setAttribute('data-ready', 'false'); } })().finally(() => { statisticsRenderPromise = null; }); return statisticsRenderPromise; } function renderPaginationControls(currentPage, totalPages, tabName = 'progress') { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; // Check if pagination already exists for this tab const tabContent = watchlistSection.querySelector(`div[data-tab="${tabName}"]`); const paginatedContainer = tabContent ? tabContent.querySelector('.paginated-container') : null; if (!paginatedContainer) return; // Check for existing pagination controls const existingTopPagination = paginatedContainer.querySelector('.pagination-top'); const existingBottomPagination = paginatedContainer.querySelector('.pagination-bottom'); // If totalPages <= 1, remove existing pagination and return if (totalPages <= 1) { if (existingTopPagination) existingTopPagination.remove(); if (existingBottomPagination) existingBottomPagination.remove(); return; // Don't show pagination for single page } // Check if pagination already exists and is correct // If pagination exists and shows the same page, just update active states if (existingTopPagination && existingBottomPagination) { const currentPageFromPagination = existingTopPagination.querySelector('.pagination-info')?.textContent; const expectedPageInfo = `Page ${currentPage} of ${totalPages}`; if (currentPageFromPagination === expectedPageInfo) { // Just update active states without recreating updatePaginationActiveStates(existingTopPagination, currentPage, totalPages); updatePaginationActiveStates(existingBottomPagination, currentPage, totalPages); return; } } // Remove existing pagination for this specific tab only if (existingTopPagination) existingTopPagination.remove(); if (existingBottomPagination) existingBottomPagination.remove(); // Create pagination container const paginationContainer = document.createElement('div'); paginationContainer.className = 'pagination'; paginationContainer.innerHTML = `
Page ${currentPage} of ${totalPages}
${generatePageNumbers(currentPage, totalPages)}
`; // Function to add event listeners to pagination container const addPaginationListeners = (container) => { container.addEventListener('click', (e) => { const button = e.target.closest('.pagination-btn'); if (!button || button.classList.contains('disabled')) return; const page = parseInt(button.dataset.page); LOG(`Pagination clicked: page ${page}, current page ${currentPage}, tab ${tabName}`); if (page && page !== currentPage) { // Get search terms based on tab const searchTerm = tabName === 'progress' ? progressCache.searchTerm : ''; const movieSearchTerm = tabName === 'history' ? movieCache.searchTerm : ''; LOG(`Navigating to page ${page} with search terms: progress="${searchTerm}", movie="${movieSearchTerm}"`); updateUrlWithSearch(tabName, page, searchTerm, movieSearchTerm); if (tabName === 'history') { renderHistoryContent(); } else { renderProgressContent(); } } }); }; // Add event listeners to the original container addPaginationListeners(paginationContainer); // Insert pagination controls at both top and bottom of paginated container if (paginatedContainer) { // Top pagination const topPagination = paginationContainer.cloneNode(true); topPagination.classList.add('pagination-top'); addPaginationListeners(topPagination); // Add event listeners to cloned container paginatedContainer.insertBefore(topPagination, paginatedContainer.firstChild); // Bottom pagination const bottomPagination = paginationContainer.cloneNode(true); bottomPagination.classList.add('pagination-bottom'); addPaginationListeners(bottomPagination); // Add event listeners to cloned container paginatedContainer.appendChild(bottomPagination); } } function updatePaginationActiveStates(paginationElement, currentPage, totalPages) { // Update page info const pageInfo = paginationElement.querySelector('.pagination-info'); if (pageInfo) { pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; } // Update button states const buttons = paginationElement.querySelectorAll('.pagination-btn'); buttons.forEach(button => { const page = parseInt(button.dataset.page); button.classList.remove('active', 'disabled'); if (page === currentPage) { button.classList.add('active'); } else if ((page === 1 || page === currentPage - 1) && currentPage === 1) { button.classList.add('disabled'); } else if ((page === totalPages || page === currentPage + 1) && currentPage === totalPages) { button.classList.add('disabled'); } }); } function generatePageNumbers(currentPage, totalPages) { const maxVisible = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); let endPage = Math.min(totalPages, startPage + maxVisible - 1); // Adjust start if we're near the end if (endPage - startPage + 1 < maxVisible) { startPage = Math.max(1, endPage - maxVisible + 1); } let pages = ''; // Add first page and ellipsis if needed if (startPage > 1) { pages += ``; if (startPage > 2) { pages += '...'; } } // Add visible pages for (let i = startPage; i <= endPage; i++) { pages += ``; } // Add last page and ellipsis if needed if (endPage < totalPages) { if (endPage < totalPages - 1) { pages += '...'; } pages += ``; } return pages; } async function createProgressCard(progress) { const { series, watchedCount, totalEpisodes, remainingCount, percentage, watchedRuntime, remainingRuntime, lastWatchedEpisode } = progress; const card = document.createElement('div'); card.className = 'progress-card'; card.dataset.seriesId = series.Id; // Add completed class for 100% shows if (percentage === 100) { card.classList.add('progress-card-completed'); } // Get series image URL const imageUrl = window.innerWidth > 768 ? series.ImageTags && series.ImageTags.Primary ? `${ApiClient._serverAddress}/Items/${series.Id}/Images/Primary?maxHeight=200&maxWidth=140&tag=${series.ImageTags.Primary}` : '' : series.ImageTags && series.ImageTags.Banner ? `${ApiClient._serverAddress}/Items/${series.Id}/Images/Banner?maxWidth=300&height=60&tag=${series.ImageTags.Banner}` : ''; // Format last watched info (only if we have episode data) let lastWatchedInfo = ''; if (lastWatchedEpisode) { const timeAgo = formatTimeAgo(lastWatchedEpisode.UserData.LastPlayedDate); const formattedDate = formatLastWatchedDate(lastWatchedEpisode.UserData.LastPlayedDate); const episodeInfo = `${lastWatchedEpisode.ParentIndexNumber}x${lastWatchedEpisode.IndexNumber.toString().padStart(2, '0')} "${lastWatchedEpisode.Name}"`; lastWatchedInfo = `Last watched ${episodeInfo} ${timeAgo} on ${formattedDate}.`; } card.innerHTML = `
${series.Name}

${series.Name} ${percentage === 100 ? '✓ Complete' : ''}

${percentage}%
${percentage === 100 ? `Watched ${watchedCount} of ${totalEpisodes} episodes - Series Complete!` : `Watched ${watchedCount} of ${totalEpisodes} episodes which leaves ${remainingCount} episodes left to watch.` }
${lastWatchedInfo ? `
${lastWatchedInfo}
` : ''}
`; // Add click handler for unwatched episodes toggle const unwatchedToggle = card.querySelector('.unwatched-toggle'); const unwatchedContainer = card.querySelector('.unwatched-episodes-container'); unwatchedToggle.addEventListener('click', async () => { if (unwatchedContainer.style.display === 'none') { // Show unwatched episodes await loadUnwatchedEpisodes(series.Id, unwatchedContainer); unwatchedContainer.style.display = 'block'; unwatchedToggle.textContent = '- Hide Unwatched Episodes'; } else { // Hide unwatched episodes unwatchedContainer.style.display = 'none'; unwatchedToggle.textContent = '+ View Unwatched Episodes'; } }); // Add click handler for mark all as watched button const markAllWatchedBtn = card.querySelector('.mark-all-watched'); if (markAllWatchedBtn) { markAllWatchedBtn.addEventListener('click', async () => { // Get series info for confirmation modal const seriesInfo = { name: series.Name, watchedCount: progress.watchedCount, totalEpisodes: progress.totalEpisodes, unwatchedCount: progress.remainingCount, percentage: progress.percentage }; showMarkAllEpisodesConfirmation(series.Id, markAllWatchedBtn, seriesInfo); }); } // Create chunked progress bar await createChunkedProgressBar(card, progress); return card; } // Helper function to expand multi-part episodes into individual parts function expandMultiPartEpisodes(episode) { // If no IndexNumberEnd or it's the same as IndexNumber, it's a single episode if (!episode.IndexNumberEnd || episode.IndexNumberEnd === episode.IndexNumber) { return [{ ...episode, expandedIndex: episode.IndexNumber }]; } // Multi-part episode - expand into individual parts const parts = []; for (let i = episode.IndexNumber; i <= episode.IndexNumberEnd; i++) { parts.push({ ...episode, expandedIndex: i }); } return parts; } // Function to generate binary progress data from episodes function generateBinaryProgressData(episodes) { const today = new Date(); today.setHours(23, 59, 59, 999); // Filter to aired episodes only and expand multi-part episodes const airedEpisodes = episodes.filter(ep => ep.PremiereDate && new Date(ep.PremiereDate) <= today ); // Expand multi-part episodes into individual parts const expandedEpisodes = []; airedEpisodes.forEach(episode => { expandedEpisodes.push(...expandMultiPartEpisodes(episode)); }); // Group by season and sort by expandedIndex const seasonProgress = {}; expandedEpisodes.forEach(episode => { const season = episode.ParentIndexNumber; if (!seasonProgress[season]) { seasonProgress[season] = []; } seasonProgress[season].push(episode); }); // Generate binary strings for each season const binaryData = {}; Object.keys(seasonProgress).forEach(season => { const seasonEpisodes = seasonProgress[season].sort((a, b) => a.expandedIndex - b.expandedIndex); const binaryString = seasonEpisodes.map(ep => (ep.UserData && ep.UserData.Played === true) ? '1' : '0' ).join(''); binaryData[season] = binaryString; }); return binaryData; } // Function to create progress bar from binary data function createBinaryProgressBar(progressBar, binaryData, seriesId) { progressBar.innerHTML = ''; progressBar.setAttribute('data-series-id', seriesId); Object.keys(binaryData).sort((a, b) => parseInt(a) - parseInt(b)).forEach(season => { const binaryString = binaryData[season]; // Create chunks for this season for (let i = 0; i < binaryString.length; i++) { const isWatched = binaryString[i] === '1'; const chunk = document.createElement('div'); chunk.className = `progress-chunk ${isWatched ? 'watched' : 'unwatched'} binary-chunk`; chunk.dataset.isWatched = isWatched; chunk.setAttribute('data-season', season); chunk.setAttribute('data-episode', i + 1); chunk.title = `S${season}:E${i + 1} (${isWatched ? 'Watched' : 'Unwatched'})`; // Add click handler for all episodes chunk.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const playState = chunk.dataset.isWatched === 'true'; // Show confirmation modal - episode details will be enhanced if available const episodeInfo = { title: `Episode ${i + 1}`, // Placeholder name season: parseInt(season), episode: i + 1, seriesId: seriesId }; const episodeId = chunk.getAttribute('data-episode-id'); showEpisodeWatchedConfirmation(episodeId, chunk, episodeInfo, seriesId, playState); }); progressBar.appendChild(chunk); } }); } // Function to enhance progress bar with episode details function enhanceProgressBarWithEpisodes(progressBar, episodes) { return; const today = new Date(); today.setHours(23, 59, 59, 999); // Filter to aired episodes only const airedEpisodes = episodes.filter(ep => ep.PremiereDate && new Date(ep.PremiereDate) <= today ); // Group episodes by season const seasonEpisodes = {}; airedEpisodes.forEach(episode => { const season = episode.ParentIndexNumber; if (!seasonEpisodes[season]) { seasonEpisodes[season] = []; } seasonEpisodes[season].push(episode); }); // Enhance each chunk with episode details const chunks = progressBar.querySelectorAll('.binary-chunk'); chunks.forEach(chunk => { const season = parseInt(chunk.getAttribute('data-season')); const episodeNum = parseInt(chunk.getAttribute('data-episode')); const seasonEpisodesList = seasonEpisodes[season]; if (seasonEpisodesList) { // Find the original episode that contains this episode number // This handles both single episodes and multi-part episodes const episode = seasonEpisodesList.find(ep => { const start = ep.IndexNumber; const end = ep.IndexNumberEnd || ep.IndexNumber; return episodeNum >= start && episodeNum <= end; }); if (episode) { // Update chunk with episode details chunk.setAttribute('data-episode-id', episode.Id); chunk.setAttribute('data-episode-title', episode.Name); // If this is a multi-part episode, show which part this chunk represents const partInfo = (episode.IndexNumberEnd && episode.IndexNumberEnd !== episode.IndexNumber) ? ` (Part ${episodeNum - episode.IndexNumber + 1} of ${episode.IndexNumberEnd - episode.IndexNumber + 1})` : ''; chunk.title = `S${season}:E${episodeNum} - ${episode.Name}${partInfo} (${episode.UserData && episode.UserData.Played === true ? 'Watched' : 'Unwatched'})`; chunk.classList.remove('binary-chunk'); // Remove binary class // Add click handler for all episodes chunk.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const isWatched = episode.UserData && episode.UserData.Played === true; const episodeInfo = { title: episode.Name, season: season, episode: episodeNum, seriesId: episode.SeriesId }; // Always show the confirmation modal showEpisodeWatchedConfirmation(episode.Id, chunk, episodeInfo, episode.SeriesId, isWatched); }); } } }); } // Function to update progress bar in place without recreating enhanced chunks async function updateProgressBarInPlace(progressCard, updatedProgress) { const progressBar = progressCard.querySelector('.progress-bar'); if (!progressBar) return; const seriesId = updatedProgress.series.Id; // Check if we have binary progress data if (updatedProgress.binaryProgress) { // Update existing chunks based on binary data const existingChunks = progressBar.querySelectorAll('.progress-chunk'); existingChunks.forEach(chunk => { const season = chunk.getAttribute('data-season'); const episode = parseInt(chunk.getAttribute('data-episode')); const isWatched = updatedProgress.binaryProgress[season] && updatedProgress.binaryProgress[season][episode - 1] === '1'; chunk.dataset.isWatched = isWatched; // Update chunk state chunk.classList.toggle('watched', isWatched); chunk.classList.toggle('unwatched', !isWatched); // Update title if (chunk.title) { chunk.title = chunk.title.replace(/\((Watched|Unwatched)\)/, isWatched ? '(Watched)' : '(Unwatched)'); } }); // If we have episodes data, enhance any remaining binary chunks if (updatedProgress.episodes && updatedProgress.episodes.length > 0) { setTimeout(async () => { try { enhanceProgressBarWithEpisodes(progressBar, updatedProgress.episodes); } catch (err) { ERR(`Failed to enhance progress bar for series ${seriesId}:`, err); } }, 100); } } else { // Fallback to full recreation if no binary data await createChunkedProgressBar(progressCard, updatedProgress); } } // Function to fetch episodes on-demand from API with in-memory caching async function fetchEpisodesForSeries(seriesId) { // First check if we have episodes in memory cache const cachedProgress = getCachedSeriesProgress(seriesId); if (cachedProgress && cachedProgress.episodes) { LOG(`Using in-memory episodes cache for series: ${seriesId} (${cachedProgress.episodes.length} episodes)`); return cachedProgress.episodes; } const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { LOG(`Fetching episodes from API for series: ${seriesId}`); const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData,RunTimeTicks&EnableImageTypes=Primary`; const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const episodesData = await episodesRes.json(); let episodes = episodesData.Items || []; // Filter and deduplicate episodes episodes = episodes .filter(ep => ep.ParentIndexNumber !== 0) .filter((ep, index, self) => index === self.findIndex(e => e.ParentIndexNumber === ep.ParentIndexNumber && e.IndexNumber === ep.IndexNumber ) ); // Cache episodes in memory (not localStorage) if (cachedProgress) { cachedProgress.episodes = episodes; LOG(`Cached ${episodes.length} episodes in memory for series: ${seriesId}`); } LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`); return episodes; } catch (err) { ERR(`Failed to fetch episodes for series ${seriesId}:`, err); return []; } } // Function to load and display unwatched episodes async function loadUnwatchedEpisodes(seriesId, container) { const listContainer = container.querySelector('.unwatched-episodes-list'); // Show loading state listContainer.innerHTML = '
Loading unwatched episodes...
'; try { // Fetch episodes on-demand from API const episodes = await fetchEpisodesForSeries(seriesId); // Filter unwatched episodes const unwatchedEpisodes = episodes.filter(ep => ep.ParentIndexNumber !== 0 && (!ep.UserData.Played || ep.UserData.Played === false) ); if (unwatchedEpisodes.length === 0) { listContainer.innerHTML = '
No unwatched episodes found
'; return; } // Sort episodes by season and episode number const sortedEpisodes = [...unwatchedEpisodes].sort((a, b) => { if (a.ParentIndexNumber !== b.ParentIndexNumber) { return a.ParentIndexNumber - b.ParentIndexNumber; } return a.IndexNumber - b.IndexNumber; }); // Create episode list HTML const episodesHtml = sortedEpisodes.map(episode => { const seasonEpisode = `${episode.ParentIndexNumber}x${episode.IndexNumber.toString().padStart(2, '0')}`; const episodeTitle = episode.Name || 'Untitled Episode'; const episodeDate = episode.PremiereDate ? new Date(episode.PremiereDate).toLocaleDateString() : ''; // Check if episode has a valid image const hasImage = episode.ImageTags && episode.ImageTags.Primary; const episodeImageHtml = hasImage ? `${episodeTitle}` : `
`; return `
${episodeImageHtml}
${episodeDate ? `
${episodeDate}
` : ''}
`; }).join(''); listContainer.innerHTML = episodesHtml; // Add event listeners for watched toggle buttons const watchedToggleButtons = listContainer.querySelectorAll('.episode-watched-toggle'); watchedToggleButtons.forEach(button => { button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const episodeItem = button.closest('.unwatched-episode-item'); if (!episodeItem) { return; } const episodeName = episodeItem.getAttribute('data-name'); const episodeId = episodeItem.getAttribute('data-episode-id'); const seriesId = episodeItem.getAttribute('data-series-id'); const season = episodeItem.getAttribute('data-season'); const episode = episodeItem.getAttribute('data-episode'); const episodeInfo = { title: episodeName ? episodeName.replace(/^S\d+:E\d+\s*-\s*/, '') : 'Unknown Episode', // Remove season/episode prefix season: season, episode: episode, seriesId: seriesId }; showEpisodeWatchedConfirmation(episodeId, button, episodeInfo); }); }); } catch (err) { ERR('Error loading unwatched episodes:', err); listContainer.innerHTML = '
Error loading episodes
'; } } // Function to toggle watched status of an individual episode async function toggleEpisodeWatchedStatus(episodeId, buttonElement, isWatched = true) { LOG(`${isWatched ? 'Marking' : 'Removing'} episode as watched: ${episodeId || 'binary mode'}`); // If we don't have an episode ID (binary mode), we need to fetch episodes first if (!episodeId) { const seriesId = buttonElement.closest('.progress-card')?.querySelector('[data-series-id]')?.getAttribute('data-series-id'); if (!seriesId) { ERR('Cannot find series ID for binary mode episode'); return; } // Fetch episodes to get the actual episode ID const episodes = await fetchEpisodesForSeries(seriesId); const season = parseInt(buttonElement.getAttribute('data-season')); const episodeNum = parseInt(buttonElement.getAttribute('data-episode')); const seasonEpisodes = episodes.filter(ep => ep.ParentIndexNumber === season); const episode = seasonEpisodes.find(ep => ep.IndexNumber === episodeNum); if (!episode) { ERR(`Episode not found: S${season}:E${episodeNum}`); return; } episodeId = episode.Id; } const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { // Mark or unmark episode as played using Jellyfin API const markPlayedUrl = `${serverUrl}/Users/${userId}/PlayedItems/${episodeId}`; const response = await fetch(markPlayedUrl, { method: isWatched ? 'POST' : 'DELETE', headers: { "Authorization": window.apiHelper.getAuthHeader(), "Content-Type": "application/json" } }); if (!response.ok) { throw new Error(`Failed to ${isWatched ? 'mark' : 'unmark'} episode as watched: ${response.status}`); } // Update cache immediately after successful API call const seriesId = episodeId ? await getSeriesIdFromEpisode(episodeId) : buttonElement.closest('.progress-card')?.querySelector('[data-series-id]')?.getAttribute('data-series-id'); if (seriesId) { const cacheUpdated = await updateEpisodeWatchedStatusInCache(seriesId, episodeId, isWatched); if (cacheUpdated) { // If episodes weren't cached, attempt a direct binaryProgress flip for the specific season/episode const cached = getCachedSeriesProgress(seriesId); if (cached && !Array.isArray(cached.episodes) && cached.binaryProgress && buttonElement && buttonElement.classList.contains('progress-chunk')) { const season = String(buttonElement.getAttribute('data-season')); const epNum = parseInt(buttonElement.getAttribute('data-episode')); // 1-based index if (season && Number.isFinite(epNum) && cached.binaryProgress[season]) { // Find which episode object this corresponds to for multi-part handling // We need to get the actual episode to check if it's multi-part // For now, just update the single bit, multi-part will be handled by recalculating const idx = epNum - 1; const bin = cached.binaryProgress[season]; if (idx >= 0 && idx < bin.length) { cached.binaryProgress[season] = bin.substring(0, idx) + (isWatched ? '1' : '0') + bin.substring(idx + 1); cacheSeriesProgress(seriesId, cached); } } } // If the action came from a progress chunk, update that chunk in-place if (buttonElement && buttonElement.classList.contains('progress-chunk')) { const chunkEl = buttonElement; if (isWatched) { chunkEl.classList.add('watched'); chunkEl.classList.remove('unwatched'); if (chunkEl.title) { chunkEl.title = chunkEl.title.replace(/\(Unwatched\)/, '(Watched)'); } } else { chunkEl.classList.add('unwatched'); chunkEl.classList.remove('watched'); if (chunkEl.title) { chunkEl.title = chunkEl.title.replace(/\(Watched\)/, '(Unwatched)'); } } } // Update the progress card UI (percentages, counts, etc.) await updateProgressCardUI(seriesId); } } // Update UI - remove the episode from the list const episodeItem = buttonElement.closest('.unwatched-episode-item'); if (episodeItem) { episodeItem.style.opacity = '0.5'; episodeItem.style.transition = 'opacity 0.3s ease'; // Remove the episode after a short delay setTimeout(() => { episodeItem.remove(); // Check if no more episodes remain const listContainer = episodeItem.closest('.unwatched-episodes-list'); if (listContainer) { const remainingEpisodes = listContainer.querySelectorAll('.unwatched-episode-item'); if (remainingEpisodes.length === 0) { listContainer.innerHTML = '
No unwatched episodes found
'; } } }, 300); } LOG(`Successfully marked episode as watched: ${episodeId}`); } catch (err) { ERR('Error toggling episode watched status:', err); // You could add a toast notification here if desired } } // Function to mark all unwatched episodes as watched async function markAllEpisodesAsWatched(seriesId, buttonElement) { LOG(`Marking all episodes as watched for series: ${seriesId}`); // Get cached progress data for this series const cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress) { ERR('Series progress data not found in cache'); return; } // Fetch unwatched episodes on-demand from API const episodes = await fetchEpisodesForSeries(seriesId); const unwatchedEpisodes = episodes.filter(ep => ep.ParentIndexNumber !== 0 && (!ep.UserData.Played || ep.UserData.Played === false) ); if (unwatchedEpisodes.length === 0) { LOG('No unwatched episodes to mark as watched'); return; } const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); // Disable button and show loading state buttonElement.disabled = true; buttonElement.textContent = 'Marking...'; try { // Mark all episodes as played in parallel const markPromises = unwatchedEpisodes.map(async (episode) => { const markPlayedUrl = `${serverUrl}/Users/${userId}/PlayedItems/${episode.Id}`; const response = await fetch(markPlayedUrl, { method: 'POST', headers: { "Authorization": window.apiHelper.getAuthHeader(), "Content-Type": "application/json" } }); if (!response.ok) { throw new Error(`Failed to mark episode ${episode.Id} as watched: ${response.status}`); } return episode.Id; }); // Wait for all requests to complete const completedEpisodes = await Promise.all(markPromises); LOG(`Successfully marked ${completedEpisodes.length} episodes as watched`); // Update cache for all episodes (without localStorage updates) completedEpisodes.forEach(episodeId => { updateEpisodeWatchedStatusInCacheWithoutLocalStorage(seriesId, episodeId, true); }); // Update localStorage once for all changes updateProgressDataInLocalStorage(); // Update the progress card UI await updateProgressCardUI(seriesId); // Update the last watched display with the last episode from the batch const progressCard = buttonElement.closest('.progress-card'); if (progressCard && unwatchedEpisodes.length > 0) { const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); // Get the last episode from the batch (should be sorted by season/episode) // Find the episode with the highest season and episode number const lastEpisode = unwatchedEpisodes.reduce((last, current) => { const lastSeason = last.ParentIndexNumber || 0; const currentSeason = current.ParentIndexNumber || 0; if (currentSeason > lastSeason) return current; if (currentSeason < lastSeason) return last; // Same season, compare episode number const lastEp = last.IndexNumber || 0; const currentEp = current.IndexNumber || 0; return currentEp > lastEp ? current : last; }); if (lastWatchedElement && lastEpisode) { const episodeInfo = { title: lastEpisode.Name, season: lastEpisode.ParentIndexNumber || 0, episode: lastEpisode.IndexNumber || 0 }; updateLastWatched(lastWatchedElement, episodeInfo); } } if (progressCard) { const unwatchedContainer = progressCard.querySelector('.unwatched-episodes-container'); const listContainer = unwatchedContainer.querySelector('.unwatched-episodes-list'); listContainer.innerHTML = '
No unwatched episodes found
'; // Hide the unwatched episodes container unwatchedContainer.style.display = 'none'; // Update the toggle button text const unwatchedToggle = progressCard.querySelector('.unwatched-toggle'); unwatchedToggle.style.display = 'none'; // Hide the mark all button since there are no more unwatched episodes buttonElement.style.display = 'none'; } } catch (err) { ERR('Error marking all episodes as watched:', err); // Re-enable button on error buttonElement.disabled = false; buttonElement.textContent = '+ Mark All As Watched'; } } // Compare existing DOM data-ids with new data-ids function compareDataIds(existingContainer, newItems) { if (!existingContainer || !newItems) return false; // Get existing data-ids from DOM const existingIds = Array.from(existingContainer.querySelectorAll('.itemsContainer > div.card[data-id]')) .map(el => el.getAttribute('data-id')) .sort(); // Get new data-ids from items const newIds = newItems.map(item => item.Id).sort(); // Compare arrays if (existingIds.length !== newIds.length) return false; return existingIds.every((id, index) => id === newIds[index]); } // Render cards from in-memory index (legacy helper; progressive path is preferred) async function renderCardsFromCache(container, sectionName, type) { if (!container) { WARN("Container not found for type:", type); return { type, itemCount: 0 }; } const items = watchlistItemsByType[type] || []; if (!items.length) { container.style.display = 'none'; return { type, itemCount: 0 }; } if (compareDataIds(container, items)) { LOG(`Skipping ${type} render - content unchanged (${items.length} items)`); container.style.display = ''; return { type, itemCount: items.length }; } container.style.display = ''; if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.renderCards) { const scrollableContainer = window.cardBuilder.renderCards(items, getTypeDisplayName(type), null); container.innerHTML = ''; container.appendChild(scrollableContainer); LOG(`Rendered ${items.length} ${type} items using cardBuilder from cache`); } else { ERR("cardBuilder not available - this should not happen with proper dependency management"); container.style.display = 'none'; return { type, itemCount: 0 }; } return { type, itemCount: items.length }; } function getTypeDisplayName(itemType) { const typeMap = { 'Movie': 'Movies', 'Series': 'Shows', 'Season': 'Seasons', 'Episode': 'Episodes' }; return typeMap[itemType] || itemType; } /************ Tab Functionality ************/ // Function to switch between tabs function switchTab(activeTab) { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; // Remove active class from all buttons const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button'); buttons.forEach(button => button.classList.remove('active')); // Hide all tab content (only the content divs, not the tab buttons) const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)'); tabContents.forEach(content => { content.style.display = 'none'; }); // Show selected tab content and activate button const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${activeTab}"]`); const activeContent = watchlistSection.querySelector(`div[data-tab="${activeTab}"]:not(.watchlist-tabs)`); if (activeButton) activeButton.classList.add('active'); if (activeContent) activeContent.style.display = 'block'; // Update URL parameters - preserve current page and sort state for this tab // Only preserve page for paginated tabs (progress and history) const currentPage = (activeTab === 'progress' || activeTab === 'history') ? (tabStates[activeTab]?.currentPage || 1) : 1; // Preserve current sort state when switching tabs let currentSort = null; let currentSortDirection = null; let currentMovieSort = null; let currentMovieSortDirection = null; // Read sort state from the tab we're switching to (preserve its state) if (activeTab === 'progress') { currentSort = tabStates[activeTab]?.currentSort || 'lastWatched'; currentSortDirection = tabStates[activeTab]?.currentDirection || 'desc'; } else if (activeTab === 'history') { currentMovieSort = tabStates[activeTab]?.currentSort || 'lastWatched'; currentMovieSortDirection = tabStates[activeTab]?.currentDirection || 'desc'; } updateUrlParams(activeTab, currentPage, currentSort, currentSortDirection, currentMovieSort, currentMovieSortDirection); // Render content based on active tab (lazy-init progress/history/statistics) if (activeTab === 'progress') { initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err)); } else if (activeTab === 'watchlist') { renderWatchlistContent(); } else if (activeTab === 'history') { initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err)); } else if (activeTab === 'statistics') { initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err)); } LOG(`Switched to ${activeTab} tab`); } // Function to set active tab based on URL params or default to watchlist function setActiveTab() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; // Check for active tab const activeTab = watchlistSection.querySelector('.watchlist-tabs button.active'); // Get tab from URL parameters const params = getUrlParams(); const targetTab = params.pageTab || 'watchlist'; if (activeTab && activeTab.getAttribute('data-tab') === targetTab) { LOG('Active tab found, skipping'); return; } // Validate that the tab exists const validTabs = ['watchlist', 'progress', 'history', 'statistics']; const finalTab = validTabs.includes(targetTab) ? targetTab : 'watchlist'; // Remove active class from all buttons const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button'); buttons.forEach(button => button.classList.remove('active')); // Hide all tab content const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)'); tabContents.forEach(content => { content.style.display = 'none'; }); // Show target tab and activate button const targetButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${finalTab}"]`); const targetContent = watchlistSection.querySelector(`div[data-tab="${finalTab}"]:not(.watchlist-tabs)`); if (targetButton) targetButton.classList.add('active'); if (targetContent) targetContent.style.display = 'block'; LOG(`Active tab set to ${finalTab} (from ${params.pageTab ? 'URL param' : 'default'})`); } // Function to setup tab event listeners function setupTabListeners() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return; // Check if listeners are already setup to prevent duplicates if (watchlistSection.dataset.listenersSetup === 'true') { LOG('Tab listeners already setup, skipping'); return; } const tabButtons = watchlistSection.querySelectorAll('.watchlist-tabs button'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabName = button.getAttribute('data-tab'); switchTab(tabName); }); }); // Setup search functionality for Progress tab setupProgressSearch(); setupMovieSearch(); // Setup "Show All" button for top shows bindShowAllShowsButton(watchlistSection); // Mark as setup to prevent duplicates watchlistSection.dataset.listenersSetup = 'true'; LOG('Tab event listeners setup complete'); } // Function to setup progress search functionality function setupProgressSearch() { const searchInput = getElementByIdSafe('progress-search'); const clearBtn = getElementByIdSafe('progress-search-clear'); if (!searchInput || !clearBtn) { WARN('Progress search elements not found:', { searchInput: !!searchInput, clearBtn: !!clearBtn }); return; } // Check if already setup to prevent duplicates if (searchInput.dataset.searchSetup === 'true') { LOG('Progress search already setup, skipping'); return; } // Debounced search function let searchTimeout; const debouncedSearch = (searchTerm) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { updateSearchResults(searchTerm); // Update URL with search term const params = getUrlParams(); updateUrlWithSearch(params.pageTab, 1, searchTerm); // Reset to page 1 when searching renderProgressContent(); }, 300); // 300ms delay }; // Search input event searchInput.addEventListener('input', (e) => { const searchTerm = e.target.value; // Show/hide clear button if (searchTerm.trim() === '') { clearBtn.style.display = 'none'; } else { clearBtn.style.display = 'flex'; } // Update search results debouncedSearch(searchTerm); }); // Clear button event clearBtn.addEventListener('click', () => { searchInput.value = ''; clearBtn.style.display = 'none'; updateSearchResults(''); // Update URL to remove search parameter const params = getUrlParams(); updateUrlWithSearch(params.pageTab, 1, ''); // Reset to page 1 when clearing renderProgressContent(); }); // Mark as setup to prevent duplicates searchInput.dataset.searchSetup = 'true'; LOG('Progress search functionality setup complete'); } // Function to setup movie search functionality function setupMovieSearch() { const searchInput = getElementByIdSafe('movie-search'); const clearBtn = getElementByIdSafe('movie-search-clear'); if (!searchInput || !clearBtn) { WARN('Movie search elements not found:', { searchInput: !!searchInput, clearBtn: !!clearBtn }); return; } // Check if already setup to prevent duplicates if (searchInput.dataset.searchSetup === 'true') { LOG('Movie search already setup, skipping'); return; } // Debounced search function let searchTimeout; const debouncedSearch = (searchTerm) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { updateMovieSearchResults(searchTerm); // Update URL with search term const params = getUrlParams(); updateUrlWithSearch(params.pageTab, 1, '', searchTerm); // Reset to page 1 when searching renderHistoryContent(); }, 300); // 300ms delay }; // Search input event searchInput.addEventListener('input', (e) => { const searchTerm = e.target.value; // Show/hide clear button if (searchTerm.trim() === '') { clearBtn.style.display = 'none'; } else { clearBtn.style.display = 'flex'; } // Update search results debouncedSearch(searchTerm); }); // Clear button event clearBtn.addEventListener('click', () => { searchInput.value = ''; clearBtn.style.display = 'none'; updateMovieSearchResults(''); // Update URL to remove search parameter const params = getUrlParams(); updateUrlWithSearch(params.pageTab, 1, '', ''); // Reset to page 1 when clearing renderHistoryContent(); }); // Mark as setup to prevent duplicates searchInput.dataset.searchSetup = 'true'; LOG('Movie search functionality setup complete'); } /************ Watchlist Section Observer ************/ // Function to render watchlist content when .sections.watchlist is found async function renderWatchlistContent() { try { if (!tabStates.watchlist.isDataFetched) { hideEmptyWatchlistMessage(); await initWatchlistTab(); return; } await renderWatchlistProgressive(false); } catch (err) { ERR('Error rendering watchlist cards:', err); } } // Function to check if watchlist section exists and render content async function renderWatchlist() { // Wait for user to be logged in if (window.userHelper?.waitForLogin) { await window.userHelper.waitForLogin(); } const watchlistSection = getWatchlistSection(); if (watchlistSection) { // Check if this is a new watchlist section (no HTML structure) /* const hasTabs = watchlistSection.querySelector('.watchlist-tabs'); const isNewSection = !hasTabs; // Check if this is a recreated section by comparing content to cache const isRecreatedSection = hasTabs && !isWatchlistContentValid(watchlistSection); */ if (!watchlistSection.dataset.watchlistRendered) { LOG(`Watchlist section found and not rendered, initializing...`); // Mark as rendered to prevent duplicate initialization watchlistSection.dataset.watchlistRendered = 'true'; // Render the complete HTML structure first renderWatchlistHtml(); // Setup tab functionality setupTabListeners(); // Setup layout toggle setupLayoutToggle(); // Set active tab based on URL params or default to watchlist setActiveTab(); // Initialize all tabs in parallel with proper dependencies await initializeAllTabs(); // Render the default active tab content //renderWatchlistContent(); LOG('Watchlist section initialized with all tabs'); } } } // Function to validate if watchlist progressive host is present function isWatchlistContentValid(watchlistSection) { try { const host = watchlistSection.querySelector('.watchlist-progressive-results'); if (!host) { LOG('Watchlist progressive host missing, content invalid'); return false; } LOG('Watchlist content is valid'); watchlistSection.dataset.htmlRendered = 'true'; return true; } catch (err) { ERR('Error validating watchlist content:', err); watchlistSection.dataset.htmlRendered = 'false'; return false; } } // Page-scoped observer for watchlist section remount (attributes+class required) function setupWatchlistSectionObserver() { function isVisibleLibraryOrCustomPage(el) { return el && el.nodeType === Node.ELEMENT_NODE && el.classList && !el.classList.contains('hide') && (el.classList.contains('libraryPage') || el.classList.contains('customPage') || el.classList.contains('homePage')); } function nodeMayContainWatchlist(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; if (node.classList && node.classList.contains('sections') && node.classList.contains('watchlist')) return true; if (node.querySelector && node.querySelector('.sections.watchlist')) return true; return isVisibleLibraryOrCustomPage(node); } let emptyWatchlistObserver = null; function disconnectEmptyWatchlistObserver() { if (!emptyWatchlistObserver) return; emptyWatchlistObserver.disconnect(); emptyWatchlistObserver = null; } function checkForEmptyWatchlist() { const onWatchlistPage = window.location.pathname.includes(window.KefinTweaksUtils._watchlistUrl) || window.location.hash.includes(window.KefinTweaksUtils._watchlistUrl); if (!onWatchlistPage) { return; } const visibleWatchlistSections = document.querySelectorAll('.libraryPage:not(.hide) .sections.watchlist'); visibleWatchlistSections.forEach((section) => { if (!section.dataset.watchlistRendered) { LOG('Watchlist container found and needs render, initializing...'); renderWatchlist(); } else if (section.children.length === 0) { LOG('Watchlist container empty after remount, re-initializing...'); delete section.dataset.watchlistRendered; renderWatchlist(); } }); } function armEmptyWatchlistObserver() { const onWatchlistPage = window.location.pathname.includes(window.KefinTweaksUtils._watchlistUrl) || window.location.hash.includes(window.KefinTweaksUtils._watchlistUrl); if (!onWatchlistPage) { disconnectEmptyWatchlistObserver(); return; } checkForEmptyWatchlist(); const pageRoot = document.querySelector('.libraryPage:not(.hide)') || document.querySelector('.customPage:not(.hide)') || document.querySelector('.homePage:not(.hide)'); const target = pageRoot || document.body; if (emptyWatchlistObserver && emptyWatchlistObserver.__kefinTarget === target) { return; } disconnectEmptyWatchlistObserver(); emptyWatchlistObserver = new MutationObserver((mutations) => { let shouldCheck = false; for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (nodeMayContainWatchlist(node)) { shouldCheck = true; break; } } } else if (mutation.type === 'attributes' && mutation.attributeName === 'class') { if (isVisibleLibraryOrCustomPage(mutation.target)) shouldCheck = true; } if (shouldCheck) break; } if (shouldCheck) checkForEmptyWatchlist(); }); emptyWatchlistObserver.__kefinTarget = target; emptyWatchlistObserver.observe(target, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); } const utils = window.KefinTweaksUtils; if (utils?.onViewPage) { utils.onViewPage(() => { armEmptyWatchlistObserver(); }); } armEmptyWatchlistObserver(); } // Initialize the watchlist section observer setupWatchlistSectionObserver(); // Initialize hash change listener for browser navigation setupHashChangeListener(); function showEmptyWatchlistMessage() { // Find the empty message in the watchlist tab const emptyMessage = document.querySelector('div[data-tab="watchlist"] .watchlist-empty-message'); if (emptyMessage) { emptyMessage.style.display = 'block'; } } function hideEmptyWatchlistMessage() { const emptyMessage = document.querySelector('div[data-tab="watchlist"] .watchlist-empty-message'); if (emptyMessage) { emptyMessage.style.display = 'none'; } } // Update watchlist header stats based on current progressive paint function updateWatchlistHeaderStats() { const showsCount = watchlistItemsByType.Series.length; const seasonsCount = watchlistItemsByType.Season.length; const episodesCount = watchlistItemsByType.Episode.length; const moviesCount = watchlistItemsByType.Movie.length; const boxsetsCount = watchlistItemsByType.BoxSet.length; const playlistsCount = watchlistItemsByType.Playlist.length; const homevideosCount = watchlistItemsByType.Video.length; // Update or hide shows stats const showsStats = getElementByIdSafe('watchlist-stats-shows'); if (showsStats) { if (showsCount > 0) { showsStats.style.display = 'flex'; getElementByIdSafe('watchlist-shows-count').textContent = `${showsCount} Show${showsCount === 1 ? '' : 's'}`; } else { showsStats.style.display = 'none'; } } // Update or hide seasons stats const seasonsStats = getElementByIdSafe('watchlist-stats-seasons'); if (seasonsStats) { if (seasonsCount > 0) { seasonsStats.style.display = 'flex'; getElementByIdSafe('watchlist-seasons-count').textContent = `${seasonsCount} Season${seasonsCount === 1 ? '' : 's'}`; } else { seasonsStats.style.display = 'none'; } } // Update or hide episodes stats const episodesStats = getElementByIdSafe('watchlist-stats-episodes'); if (episodesStats) { if (episodesCount > 0) { episodesStats.style.display = 'flex'; getElementByIdSafe('watchlist-episodes-count').textContent = `${episodesCount} Episode${episodesCount === 1 ? '' : 's'}`; } else { episodesStats.style.display = 'none'; } } // Update or hide movies stats const moviesStats = getElementByIdSafe('watchlist-stats-movies'); if (moviesStats) { if (moviesCount > 0) { moviesStats.style.display = 'flex'; getElementByIdSafe('watchlist-movies-count').textContent = `${moviesCount} Movie${moviesCount === 1 ? '' : 's'}`; } else { moviesStats.style.display = 'none'; } } // Update or hide boxsets stats const boxsetsStats = getElementByIdSafe('watchlist-stats-boxsets'); if (boxsetsStats) { if (boxsetsCount > 0) { boxsetsStats.style.display = 'flex'; getElementByIdSafe('watchlist-boxsets-count').textContent = `${boxsetsCount} Box Set${boxsetsCount === 1 ? '' : 's'}`; } else { boxsetsStats.style.display = 'none'; } } // Update or hide playlists stats const playlistsStats = getElementByIdSafe('watchlist-stats-playlists'); if (playlistsStats) { if (playlistsCount > 0) { playlistsStats.style.display = 'flex'; getElementByIdSafe('watchlist-playlists-count').textContent = `${playlistsCount} Playlist${playlistsCount === 1 ? '' : 's'}`; } else { playlistsStats.style.display = 'none'; } } // Update or hide homevideos stats const homevideosStats = getElementByIdSafe('watchlist-stats-homevideos'); if (homevideosStats) { if (homevideosCount > 0) { homevideosStats.style.display = 'flex'; getElementByIdSafe('watchlist-homevideos-count').textContent = `${homevideosCount} Home Video${homevideosCount === 1 ? '' : 's'}`; } else { homevideosStats.style.display = 'none'; } } } // Setup layout toggle button functionality function setupLayoutToggle() { const layoutToggleBtn = getElementByIdSafe('watchlist-layout-toggle'); if (layoutToggleBtn) { layoutToggleBtn.addEventListener('click', () => { const libraryPage = document.querySelector('.libraryPage:not(.hide)'); const watchlistTab = libraryPage?.querySelector('div[data-tab="watchlist"]'); if (watchlistTab) { const currentLayout = watchlistTab.getAttribute('data-layout') || 'Default'; const newLayout = currentLayout === 'Default' ? 'List' : 'Default'; watchlistTab.setAttribute('data-layout', newLayout); // Update button icon based on layout const icon = layoutToggleBtn.querySelector('.material-icons'); if (icon) { if (newLayout === 'List') { icon.classList.add('view_list'); icon.classList.remove('view_module'); } else { icon.classList.add('view_module'); icon.classList.remove('view_list'); } } LOG(`Layout switched to: ${newLayout}`); } }); LOG('Layout toggle button setup complete'); } } function showEmptyProgressMessage() { const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message'); if (emptyMessage) { emptyMessage.style.display = 'block'; } } function showEmptySearchMessage() { const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message'); if (emptyMessage) { // Update the message for search results const title = emptyMessage.querySelector('.empty-message-title'); const subtitle = emptyMessage.querySelector('.empty-message-subtitle'); if (title) title.textContent = 'No Series Found'; if (subtitle) subtitle.textContent = `No series match "${progressCache.searchTerm}". Try a different search term.`; emptyMessage.style.display = 'block'; } } function hideEmptyProgressMessage() { const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message'); if (emptyMessage) { // Reset message to default const title = emptyMessage.querySelector('.empty-message-title'); const subtitle = emptyMessage.querySelector('.empty-message-subtitle'); if (title) title.textContent = 'No Progress to Show'; if (subtitle) subtitle.textContent = 'Start watching some shows to track your progress here'; emptyMessage.style.display = 'none'; } } function showEmptyHistoryMessage() { const emptyMessage = document.querySelector('div[data-tab="history"] .movie-history-empty-message'); if (emptyMessage) { emptyMessage.style.display = 'block'; } } function hideEmptyHistoryMessage() { const emptyMessage = document.querySelector('div[data-tab="history"] .movie-history-empty-message'); if (emptyMessage) { emptyMessage.style.display = 'none'; } } LOG('Watchlist functionality initialized'); // Get watchlist tab index, fetching if not yet set async function getWatchlistTabIndex() { if (_watchlistTabIndex !== null) { return _watchlistTabIndex; } const api = (typeof ApiClient !== 'undefined' && ApiClient) || window.ApiClient || null; const serverAddress = api && (api._serverAddress || (typeof api.serverAddress === 'function' ? api.serverAddress() : null)); if (!api || !serverAddress) { return _watchlistTabIndex; } const authHeader = apiHelper.getAuthHeader(); // Fetch the tab index as we do in addCustomMenuLink try { const response = await fetch(`${serverAddress}/CustomTabs/Config`, { method: "GET", headers: { "Content-Type": "application/json", "Authorization": authHeader }, }); const data = await response.json(); data.forEach((tab, index) => { if (tab.ContentHtml.indexOf('sections watchlist') !== -1) { _watchlistTabIndex = index + 2; LOG('Fetched and stored watchlist tab index:', _watchlistTabIndex); } }); } catch (err) { WARN('CustomTabs plugin not found.'); } return _watchlistTabIndex; } // Function to fetch episodes and enhance progress bars on the watchlist tab async function fetchEpisodesAndEnhanceProgressBar() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) { LOG('Watchlist section not found, skipping progress bar enhancement'); return; } const progressTab = watchlistSection.querySelector('div[data-tab="progress"]'); if (!progressTab) { LOG('Progress tab not found, skipping progress bar enhancement'); return; } // Find all progress cards with progress bars const progressCards = progressTab.querySelectorAll('.progress-card'); if (progressCards.length === 0) { LOG('No progress cards found, skipping progress bar enhancement'); return; } LOG(`Found ${progressCards.length} progress cards, enhancing progress bars with episode details`); // Enhance each progress bar for (const card of progressCards) { const progressBar = card.querySelector('.progress-bar'); if (!progressBar) continue; const seriesIdElement = card.querySelector('[data-series-id]'); if (!seriesIdElement) continue; const seriesId = seriesIdElement.getAttribute('data-series-id'); if (!seriesId) continue; // Only enhance if it has binary chunks (already rendered) const hasBinaryChunks = progressBar.querySelectorAll('.binary-chunk').length > 0; if (!hasBinaryChunks) continue; try { const episodes = await fetchEpisodesForSeries(seriesId); if (episodes && episodes.length > 0) { enhanceProgressBarWithEpisodes(progressBar, episodes); LOG(`Enhanced progress bar with episode details for series: ${seriesId}`); } } catch (err) { ERR(`Failed to enhance progress bar for series ${seriesId}:`, err); } } } async function addWatchlistPageHandler() { // Primary init is addCustomPage onShow + kefin:custompage-show; keep onViewPage as backup for home-tab URLs if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) { window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { const watchlistUrl = await window.KefinTweaksUtils.getWatchlistUrl(); if (watchlistUrl && hash.includes(watchlistUrl)) { LOG('onViewPage: Watchlist (backup)'); renderWatchlist(); } }, { pages: [] }); LOG('Registered onViewPage handler for Watchlist'); } else { WARN('KefinTweaksUtils.onViewPage not available'); } } let watchlistCustomPageShowBound = false; async function addCustomWatchlistPage() { if (!window.KefinTweaksUtils?.addCustomPage) { WARN('addCustomPage not available yet'); return false; } const watchlistUrl = await window.KefinTweaksUtils.getWatchlistUrl(); window.KefinTweaksUtils.addCustomPage( watchlistUrl, 'Watchlist', '
', () => { LOG('onShow: Watchlist'); renderWatchlist(); } ); if (!watchlistCustomPageShowBound) { watchlistCustomPageShowBound = true; document.addEventListener('kefin:custompage-show', async (e) => { const hrefPath = e.detail?.hrefPath; if (!hrefPath) return; const url = await window.KefinTweaksUtils.getWatchlistUrl(); const normalized = url && String(url).split('?')[0]; if (hrefPath === normalized || (url && hrefPath === url)) { LOG('kefin:custompage-show: Watchlist'); renderWatchlist(); } }); } LOG('Registered custom page Watchlist'); return true; } async function addCustomMenuLink() { LOG('Adding custom menu link for Watchlist'); if (!window.KefinTweaksUtils?.addCustomMenuLink) { WARN('addCustomMenuLink not available'); return; } const watchlistCfg = window.KefinTweaksFeatureConfigs?.watchlist?.normalizeConfig?.( window.KefinTweaksConfig?.watchlist ) || { sideMenu: window.KefinTweaksConfig?.watchlist?.sideMenu !== false, topNavigation: window.KefinTweaksConfig?.watchlist?.topNavigation === undefined ? 'main' : window.KefinTweaksConfig?.watchlist?.topNavigation, userMenu: window.KefinTweaksConfig?.watchlist?.userMenu !== false, order: window.KefinTweaksConfig?.watchlist?.order !== undefined ? window.KefinTweaksConfig?.watchlist?.order : 2 }; const watchlistUrl = await window.KefinTweaksUtils.getWatchlistUrl(); const isCustomTabUrl = watchlistUrl.includes('/home?tab='); const options = { sideMenu: isCustomTabUrl ? false : watchlistCfg.sideMenu !== false, userMenu: watchlistCfg.userMenu === true, topNavigation: isCustomTabUrl ? 'none' : watchlistCfg.topNavigation, order: watchlistCfg.order }; window.KefinTweaksUtils.addCustomMenuLink( 'Watchlist', 'bookmark', watchlistUrl, false, options ); LOG('Added Watchlist menu link (#/watchlist)'); } function waitForUtilsAndRegisterWatchlist() { const ready = () => { if (!window.KefinTweaksUtils?.addCustomPage || !window.KefinTweaksUtils?.addCustomMenuLink || !window.KefinTweaksUtils?.onViewPage) { return false; } return true; }; const run = async () => { if (window.userHelper?.waitForLogin) { await window.userHelper.waitForLogin(); } await addCustomWatchlistPage(); await addCustomMenuLink(); await addWatchlistPageHandler(); }; if (ready()) { run(); return; } LOG('Waiting for KefinTweaksUtils and ApiClient…'); const checkInterval = setInterval(() => { if (ready()) { clearInterval(checkInterval); run(); } }, 100); setTimeout(() => { clearInterval(checkInterval); if (!ready()) { WARN('KefinTweaksUtils/ApiClient not available after 10 seconds'); } }, 10000); } waitForUtilsAndRegisterWatchlist(); // Initialize playback monitoring initializePlaybackMonitoring(); /************ Watchlist Button Observer ************/ function bindWatchlistButton(button, itemId, itemType) { if (!button || !itemId) return; if (!button.getAttribute('data-id')) { button.setAttribute('data-id', itemId); } if (button.dataset.watchlistBound === 'true') return; button.dataset.watchlistBound = 'true'; button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const newRating = button.dataset.active === 'false' ? 'true' : 'false'; await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); const isActive = newRating === 'true'; applyWatchlistButtonActiveState(button, isActive); await updateWatchlistCacheOnToggle(itemId, itemType, isActive); }); } function bindSpotlightWatchlistButtons(root) { const scope = root && root.nodeType === Node.ELEMENT_NODE ? root : document; const buttons = new Set(); if ( scope.classList?.contains('watchlist-button') && scope.getAttribute('data-id') && (scope.classList.contains('spotlight-watchlist-button') || scope.closest?.('.spotlight-section')) ) { buttons.add(scope); } scope.querySelectorAll?.( '.spotlight-section .watchlist-button[data-id], .watchlist-button.spotlight-watchlist-button[data-id]' )?.forEach((button) => buttons.add(button)); buttons.forEach((button) => { const itemId = button.getAttribute('data-id'); if (!itemId) return; const host = button.closest('[data-item-type], [data-type]'); const itemType = button.getAttribute('data-type') || host?.getAttribute('data-item-type') || host?.getAttribute('data-type') || ''; bindWatchlistButton(button, itemId, itemType); }); } // Function to add watchlist button to a card overlay container function addWatchlistButton(overlayContainer) { if (!overlayContainer) return; const card = overlayContainer.closest('.card'); if (!card) { WARN('Could not find card parent for overlay container'); return; } const itemId = card.getAttribute('data-id'); if (!itemId) { WARN('Could not find data-id on card element'); return; } const itemType = card.getAttribute('data-type'); if (!itemType) { LOG('Card has no data-type, skipping watchlist button'); return; } if (!isWatchlistSupportedType(itemType)) { return; } if (!ApiClient._loggedIn) { LOG('User is not logged in, skipping watchlist button'); return; } const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br'); if (!buttonContainer) { WARN('Could not find .cardOverlayButton-br container'); return; } let watchlistButton = overlayContainer.querySelector('.watchlist-button'); const createdNew = !watchlistButton; if (createdNew) { watchlistButton = document.createElement('button'); watchlistButton.type = 'button'; watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat'; watchlistButton.setAttribute('data-action', 'none'); watchlistButton.setAttribute('data-id', itemId); const watchlistIcon = document.createElement('span'); watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist'; watchlistIcon.setAttribute('aria-hidden', 'true'); watchlistButton.appendChild(watchlistIcon); const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]') || buttonContainer.querySelector('button:has([data-testid="CheckIcon"])'); if (playStateButton) { buttonContainer.insertBefore(watchlistButton, playStateButton); } else { buttonContainer.appendChild(watchlistButton); } } else { watchlistButton.setAttribute('data-id', itemId); } // Prefer liked-Id cache once loaded; until then keep cardBuilder UserData.Likes state if (watchlistLikedIdsLoaded || createdNew) { applyWatchlistButtonActiveState(watchlistButton, isItemInLikedIdCache(itemId)); } bindWatchlistButton(watchlistButton, itemId, itemType); } // Function to process all existing overlay containers function processExistingOverlayContainers() { const overlayContainers = document.querySelectorAll('.cardOverlayContainer'); overlayContainers.forEach((overlayContainer) => { const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br'); if (buttonContainer) { addWatchlistButton(overlayContainer); } }); bindSpotlightWatchlistButtons(document); } function processWatchlistOverlaysInRoot(root) { if (!root || root.nodeType !== Node.ELEMENT_NODE) return; bindSpotlightWatchlistButtons(root); if (root.classList?.contains('cardOverlayContainer')) { const buttonContainer = root.querySelector('.cardOverlayButton-br'); if (buttonContainer) addWatchlistButton(root); return; } const overlayContainers = root.querySelectorAll?.('.cardOverlayContainer'); if (!overlayContainers?.length) return; overlayContainers.forEach((overlayContainer) => { const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br'); if (buttonContainer) addWatchlistButton(overlayContainer); }); } function setupWatchlistButtonObserver() { /** @type {WeakMap} */ const itemsContainerObservers = new WeakMap(); /** @type {WeakSet} */ const observedContainers = new WeakSet(); function attachItemsContainerObserver(itemsContainer) { if (!itemsContainer || observedContainers.has(itemsContainer)) return; observedContainers.add(itemsContainer); const shallow = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; processWatchlistOverlaysInRoot(node); } } }); shallow.observe(itemsContainer, { childList: true, subtree: false }); itemsContainerObservers.set(itemsContainer, shallow); processWatchlistOverlaysInRoot(itemsContainer); } function discoverItemsContainers(root) { if (!root || root.nodeType !== Node.ELEMENT_NODE) return; if (root.classList?.contains('itemsContainer')) { attachItemsContainerObserver(root); } root.querySelectorAll?.('.itemsContainer').forEach(attachItemsContainerObserver); } function onDiscoveryMutations(mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; bindSpotlightWatchlistButtons(node); // Skip deep card-internal inserts (handled by shallow itemsContainer observers) if (node.closest?.('.card') && !node.classList?.contains('itemsContainer')) { continue; } discoverItemsContainers(node); } } } const utils = window.KefinTweaksUtils; if (utils?.onBodyMutation) { utils.onBodyMutation('watchlist-overlay-discovery', onDiscoveryMutations); } else if (typeof MutationObserver !== 'undefined' && document.body) { const discoveryObserver = new MutationObserver(onDiscoveryMutations); discoveryObserver.observe(document.body, { childList: true, subtree: true }); } discoverItemsContainers(document.body); processExistingOverlayContainers(); } // Initialize the observer setupWatchlistButtonObserver(); // Hydrate liked-Id cache from localStorage; network refresh after settle + 5s scheduleWatchlistLikedIdsBaseline(); // Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes window.KefinWatchlistLikedIds = { has: isItemInLikedIdCache, set: setLikedIdInCache, syncOverlayButtons: syncOverlayWatchlistButtons, bindWatchlistButton, isSupportedType: isWatchlistSupportedType }; /************ Item Detail Page Observer ************/ // Function to add watchlist button to item detail page. // Visibility vs native Play is gated in watchlist.css via :has(.btnPlay:not(.hide)). function addDetailPageWatchlistButton(item = null) { const trailerButton = document.querySelector('.itemDetailPage:not(.hide) .btnPlayTrailer'); if (!trailerButton) { return; } // Check if watchlist button already exists const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon'); if (existingButton) { return; } const isTVLayout = document.getRootNode().documentElement.className.includes('layout-tv'); const watchlistIcon = document.createElement('button'); watchlistIcon.setAttribute('is', 'emby-button'); watchlistIcon.className = `watchlist-icon detailButton emby-button button-flat${isTVLayout ? ' show-focus' : ''}`; watchlistIcon.title = 'Add to Watchlist'; watchlistIcon.dataset.active = 'false'; const contentWrapper = document.createElement('div'); contentWrapper.className = 'detailButton-content'; const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons detailButton-icon watchlist'; iconSpan.setAttribute('aria-hidden', 'true'); contentWrapper.appendChild(iconSpan); watchlistIcon.appendChild(contentWrapper); const itemId = item?.Id || window.location.href.substring(window.location.href.indexOf('id=') + 3, window.location.href.indexOf('id=') + 35); watchlistIcon.addEventListener('click', async () => { const newRating = watchlistIcon.dataset.active === 'false' ? 'true' : 'false'; await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); watchlistIcon.dataset.active = newRating; const isActive = watchlistIcon.dataset.active === 'true'; watchlistIcon.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; const toggledItem = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId); if (toggledItem) { await updateWatchlistCacheOnToggle(itemId, toggledItem.Type, isActive); } }); const applyItemState = (resolvedItem) => { if (!resolvedItem) return; if (resolvedItem.Type !== 'Movie' && resolvedItem.Type !== 'Series' && resolvedItem.Type !== 'Season' && resolvedItem.Type !== 'Episode' && resolvedItem.Type !== 'BoxSet' && resolvedItem.Type !== 'Playlist' && resolvedItem.Type !== 'Video') { watchlistIcon.style.display = 'none'; return; } if (resolvedItem.UserData && resolvedItem.UserData.Played) { watchlistIcon.style.display = 'none'; return; } if (resolvedItem.UserData && resolvedItem.UserData.Likes) { watchlistIcon.dataset.active = 'true'; watchlistIcon.title = 'Remove from Watchlist'; } }; if (item) { applyItemState(item); } else { ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then(applyItemState).catch((err) => { ERR('Error fetching item data:', err); }); } trailerButton.after(watchlistIcon); } // Function to monitor item detail pages using onViewPage function initializeItemDetailPageHandler() { if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); setTimeout(initializeItemDetailPageHandler, 1000); return; } LOG('Registering item detail page handler with KefinTweaksUtils'); window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { const item = await itemPromise; const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)'); if (!visibleItemDetailPage) { return; } const itemId = item?.Id || ''; if (itemId && visibleItemDetailPage.dataset.watchlistButtonAdded === itemId) { return; } if (!itemId && visibleItemDetailPage.dataset.watchlistButtonAdded === 'true') { return; } const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon'); if (existingButton && itemId && existingButton.dataset.itemId === itemId) { return; } if (existingButton && itemId && existingButton.dataset.itemId !== itemId) { existingButton.remove(); } if (item && (item.Type === 'Movie' || item.Type === 'Series' || item.Type === 'Season' || item.Type === 'Episode' || item.Type === 'BoxSet' || item.Type === 'Playlist' || item.Type === 'Video')) { visibleItemDetailPage.dataset.watchlistButtonAdded = itemId || 'true'; await addDetailPageWatchlistButton(item); const btn = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon'); if (btn && itemId) btn.dataset.itemId = itemId; } }, { pages: ['details'] }); LOG('Item detail page handler initialized via KefinTweaksUtils'); } // Initialize the item detail page handler if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeItemDetailPageHandler); } else { initializeItemDetailPageHandler(); } // Function to add watchlist button to a slide function addSlideWatchlistButton(slide) { // Check if watchlist button already exists const existingButton = slide.querySelector('.watchlist-button'); if (existingButton) { return; } // Get item ID from slide const itemId = slide.getAttribute('data-item-id'); if (!itemId) { WARN('Could not find data-item-id on slide element'); return; } // Find the button container const buttonContainer = slide.querySelector('.button-container'); if (!buttonContainer) { WARN('Could not find .button-container in slide'); return; } // Find the play button to insert after it const playButton = slide.querySelector('.btnPlay.play-button'); if (!playButton) { WARN('Could not find play button in slide'); return; } // Create watchlist button (empty button, icon added via CSS ::before) const watchlistButton = document.createElement('button'); watchlistButton.type = 'button'; watchlistButton.className = 'detail-button favorite-button watchlist-button'; watchlistButton.setAttribute('data-item-id', itemId); watchlistButton.setAttribute('data-active', 'false'); watchlistButton.title = 'Add to Watchlist'; // Add click event listener watchlistButton.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); // Toggle watchlist status const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false'; await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); watchlistButton.dataset.active = newRating; // Update title based on state const isActive = watchlistButton.dataset.active === 'true'; watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; // Update watchlist cache immediately const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId); if (item) { await updateWatchlistCacheOnToggle(itemId, item.Type, isActive); } }); // Check if item type is supported for watchlist ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => { // Only show for Movies, Series, Seasons, and Episodes if (item.Type !== "Movie" && item.Type !== "Series" && item.Type !== "Season" && item.Type !== "Episode") { watchlistButton.style.display = "none"; return; } // Set initial state based on current watchlist status if (item.UserData && item.UserData.Likes) { watchlistButton.dataset.active = 'true'; watchlistButton.title = 'Remove from Watchlist'; } }).catch(err => { ERR('Error fetching item data for slide watchlist button:', err); }); const favoriteButton = slide.querySelector('.favorite-button'); if (favoriteButton) { favoriteButton.before(watchlistButton); } else { playButton.parentNode.insertBefore(watchlistButton, playButton.nextSibling); } } // Function to monitor slides container for MediaBar Plugin function monitorSlidesContainer() { const slidesObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'childList') continue; for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (node.classList?.contains('slide')) { addSlideWatchlistButton(node); } node.querySelectorAll?.('.slide')?.forEach((slide) => { addSlideWatchlistButton(slide); }); } } }); function bindSlidesContainer(slidesContainer) { if (!slidesContainer) return; slidesContainer.querySelectorAll('.slide').forEach((slide) => { addSlideWatchlistButton(slide); }); slidesObserver.observe(slidesContainer, { childList: true, subtree: true }); } const existing = document.querySelector('#slides-container'); if (existing) { bindSlidesContainer(existing); return; } const utils = window.KefinTweaksUtils; if (utils?.waitForElement) { utils.waitForElement('#slides-container', { timeout: 60000, quiet: true }).then((el) => { bindSlidesContainer(el); }); return; } // Fallback: short TTL poll if shared wait helper unavailable const start = Date.now(); const pollId = setInterval(() => { const el = document.querySelector('#slides-container'); if (el) { clearInterval(pollId); bindSlidesContainer(el); } else if (Date.now() - start > 60000) { clearInterval(pollId); } }, 250); } // Initialize the slides container observer monitorSlidesContainer(); // Debug functions for troubleshooting (available in console) window.debugWatchlistButtons = function() { LOG('Manual debug trigger called'); LOG('Current overlay containers:', document.querySelectorAll('.cardOverlayContainer').length); LOG('Current watchlist buttons:', document.querySelectorAll('.watchlist-button').length); processExistingOverlayContainers(); }; window.debugWatchlistRendering = function() { LOG('Manual watchlist rendering trigger called'); renderWatchlist(); }; window.debugWatchlistOptimization = function() { LOG('Watchlist optimization debug'); const watchlistSection = getWatchlistSection(); if (!watchlistSection) { LOG('No watchlist section found'); return; } const host = watchlistSection.querySelector('.watchlist-progressive-results'); if (!host) { LOG('Progressive host not found'); return; } const sections = host.querySelectorAll('.emby-scroller-container, .custom-scroller-container, [data-section-id]'); LOG(`Progressive host has ${host.children.length} children / ${sections.length} sections`); sections.forEach((el, index) => { const dataIds = Array.from(el.querySelectorAll('[data-id]')) .map(card => card.getAttribute('data-id')) .sort(); LOG(`Section ${index} (${el.getAttribute('data-section-id') || el.className}): ${dataIds.length} cards, data-ids=[${dataIds.join(', ')}]`); }); WATCHLIST_SECTION_TYPES.forEach(({ type, label }) => { LOG(`${label} index: ${(watchlistItemsByType[type] || []).length} items`); }); }; window.debugDetailPageWatchlist = function() { LOG('Manual detail page watchlist trigger called'); addDetailPageWatchlistButton(); }; window.debugProgressTab = function() { LOG('Manual progress tab trigger called'); renderProgressContent(); }; window.debugFetchProgress = async function() { LOG('Manual fetch progress trigger called'); const progress = await fetchAllProgressData(); LOG('Progress data:', progress); return progress; }; window.debugFetchProgressWithMissing = async function() { LOG('Manual fetch progress with missing episodes trigger called'); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const url = `${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,RecursiveItemCount`; const res = await fetch(url, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const data = await res.json(); const series = data.Items || []; const inProgressSeries = series.filter(series => { return series.UserData && series.UserData.PlayedPercentage > 0; }); const sortedSeries = sortInProgressSeries(inProgressSeries); const progressData = await Promise.all( sortedSeries.map(async (series) => { return await fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token); }) ); LOG('Progress data with missing episodes:', progressData); return progressData; }; window.debugSorting = function() { LOG('Testing sorting functionality'); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); fetch(`${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,ChildCount,RecursiveItemCount`, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }) .then(res => res.json()) .then(data => { const series = data.Items || []; const inProgressSeries = series.filter(series => { return series.UserData && series.UserData.PlayedPercentage > 0; }); LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`); const sorted = sortInProgressSeries(inProgressSeries); // Show first 10 series to verify sorting LOG('First 10 sorted series:'); sorted.slice(0, 10).forEach((s, i) => { LOG(`${i + 1}. ${s.Name} (${s.Status}) - Premiere: ${s.PremiereDate}, End: ${s.EndDate}`); }); }); }; window.debugPagination = function() { LOG('Testing pagination functionality'); LOG('Current cache state:', { hasData: progressCache.data.length > 0, totalSeries: progressCache.data.length, totalPages: progressCache.totalPages, pageSize: progressCache.pageSize, cachedProgressCount: progressCache.data.length }); if (progressCache.data.length > 0) { LOG('Testing page retrieval:'); for (let page = 1; page <= Math.min(3, progressCache.totalPages); page++) { const { pageData } = progressPagination.getCachedPage(page); LOG(`Page ${page}: ${pageData.length} progress items`); } } else { LOG('No cached data available. Run fetchAllProgressData first.'); } }; window.debugCache = function() { LOG('Cache management functions:'); LOG('clearProgressCache() - Clear all cached data'); LOG('isCacheValid() - Check if cache is still valid'); LOG('Current cache valid:', isCacheValid()); }; window.debugPageFetch = async function(page = 1) { LOG(`Testing page ${page} fetch`); const progress = await fetchProgressForPage(page); LOG(`Page ${page} progress data:`, progress); return progress; }; window.debugSeriesCache = function() { LOG('Series progress cache state:'); LOG('Cached series count:', progressCache.data.length); LOG('Cached series IDs:', progressCache.data.map(item => item.series.Id)); // Show first few cached series const cachedSeries = progressCache.data; if (cachedSeries.length > 0) { LOG('Sample cached series:'); cachedSeries.slice(0, 3).forEach(progress => { LOG(`- ${progress.series.Name}: ${progress.watchedCount}/${progress.totalEpisodes} (${progress.percentage}%)`); }); } }; window.debugPreloading = function() { LOG('Testing preloading functionality'); LOG('Current cache state:', { hasSeriesData: progressCache.data.length > 0, totalSeries: progressCache.data.length, totalPages: progressCache.totalPages, cachedProgressCount: progressCache.data.length }); // Test preloading preloadProgressData(); }; window.debugHash = function() { LOG('Current hash:', window.location.hash); LOG('Parsed params:', getUrlParams()); LOG('Test updating hash to progress page 2...'); updateUrlParams('progress', 2); }; window.debugAllProgressData = async function() { LOG('Testing new all progress data functionality'); const allData = await fetchAllProgressData(); LOG('All progress data loaded:', allData.length, 'items'); LOG('Cache state:', { allDataLoaded: progressCache.allDataLoaded, totalItems: progressCache.data.length, totalPages: progressCache.totalPages, pageSize: progressCache.pageSize }); // Show first few items with their last watched dates if (allData.length > 0) { LOG('First 5 items (sorted by last watched):'); allData.slice(0, 5).forEach((progress, i) => { const lastWatched = progress.lastWatchedEpisode && progress.lastWatchedEpisode.UserData && progress.lastWatchedEpisode.UserData.LastPlayedDate ? new Date(progress.lastWatchedEpisode.UserData.LastPlayedDate).toLocaleString() : 'Never'; LOG(`${i + 1}. ${progress.series.Name} - Last watched: ${lastWatched}`); }); } return allData; }; window.debugNewPagination = function(page = 1) { LOG(`Testing new pagination for page ${page}`); const { pageData, totalPages, currentPage } = progressPagination.getCachedPage(page); LOG(`Page ${currentPage}: ${pageData.length} items, ${totalPages} total pages`); return { pageData, totalPages, currentPage }; }; window.debugStatistics = function() { LOG('Testing statistics functionality'); if (progressCache.allDataLoaded && progressCache.data.length > 0) { const stats = calculateProgressStatistics(progressCache.data); LOG('Calculated statistics:', stats); updateProgressStatistics(progressCache.data); LOG('Statistics updated in UI'); return stats; } else { LOG('No progress data loaded yet. Run fetchAllProgressData() first.'); return null; } }; window.debugMovieHistory = async function() { LOG('Testing movie history functionality'); const movies = await fetchWatchedMovies(); LOG('Found watched movies:', movies.length); if (movies.length > 0) { LOG('Sample movies:'); movies.slice(0, 3).forEach((movie, i) => { LOG(`${i + 1}. ${movie.Name} (${movie.ProductionYear || 'Unknown year'})`); }); } return movies; }; window.debugRenderMovieHistory = function() { LOG('Testing movie history rendering'); renderHistoryContent(); }; window.debugMoviePagination = function(page = 1) { LOG(`Testing movie pagination for page ${page}`); const { pageData, totalPages, currentPage } = moviePagination.getCachedPage(page); LOG(`Page ${currentPage}: ${pageData.length} movies, ${totalPages} total pages`); return { pageData, totalPages, currentPage }; }; window.debugMovieCache = function() { LOG('Movie cache state:', { hasData: movieCache.data.length > 0, totalMovies: movieCache.data.length, totalPages: movieCache.totalPages, pageSize: movieCache.pageSize, allDataLoaded: movieCache.allDataLoaded }); if (movieCache.data.length > 0) { LOG('Sample movies:'); movieCache.data.slice(0, 3).forEach((movie, i) => { const watchedDate = movie.UserData && movie.UserData.LastPlayedDate ? new Date(movie.UserData.LastPlayedDate).toLocaleString() : 'Unknown'; LOG(`${i + 1}. ${movie.Name} - Watched: ${watchedDate}`); }); } }; // Expose functions to global scope for onclick handlers window.toggleMovieFavorite = toggleMovieFavorite; LOG('Initialized successfully'); })();