// 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:
`;
}
// 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.`);
}
// Invalidate progressive watchlist cache and re-render when membership changes
async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
try {
LOG(`Watchlist membership changed for ${itemType} ${itemId} (added=${isAdded}); refreshing progressive sections`);
setLikedIdInCache(itemId, isAdded === true);
syncOverlayWatchlistButtons();
if (itemType) {
await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType });
} else {
await invalidateAllWatchlistQueries();
}
const watchlistSection = getWatchlistSection();
const watchlistVisible = !!watchlistSection
&& !watchlistSection.closest('.libraryPage.hide')
&& !!watchlistSection.querySelector('div[data-tab="watchlist"]:not([style*="display: none"])');
if (watchlistVisible || tabStates.watchlist.isDataFetched) {
await initWatchlistTab(true);
} else {
tabStates.watchlist.isDataFetched = false;
}
} catch (err) {
ERR('Error refreshing watchlist after toggle:', 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}`);
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 = `
${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 = '
';
}
}
// 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: 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 = '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);
});
// Insert button after play button
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');
})();