// Jellyfin Watchlist Script
// Adds watchlist functionality throughout Jellyfin interface
// Requires: cardBuilder.js, localStorageCache.js, modal.js, utils.js modules to be loaded before this script
// Requirement #2: Custom Tabs plugin
/*
In the Custom Tabs plugin, add a new tab with the following HTML content:
*/
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Watchlist]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Watchlist]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Watchlist]', ...args);
LOG('Initializing...');
// Initialize localStorage cache manager
const localStorageCache = new window.LocalStorageCache();
const WATCHLIST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Playback monitoring for watchlist cleanup
let playbackMonitorInitialized = false;
// Store watchlist tab index
let _watchlistTabIndex = null;
// Data optimization functions for localStorage storage
function optimizeProgressDataForStorage(progressDataArray) {
return progressDataArray.map(progress => ({
// Series info (minimal)
series: {
Id: progress.series.Id,
Name: progress.series.Name,
ImageTags: progress.series.ImageTags,
Status: progress.series.Status,
ProductionYear: progress.series.ProductionYear
},
// Progress metrics (pre-calculated statistics only)
watchedCount: progress.watchedCount,
totalEpisodes: progress.totalEpisodes,
remainingCount: progress.remainingCount,
percentage: progress.percentage,
totalRuntime: progress.totalRuntime,
watchedRuntime: progress.watchedRuntime,
remainingRuntime: progress.remainingRuntime,
// Last watched episode (minimal)
lastWatchedEpisode: progress.lastWatchedEpisode ? {
Id: progress.lastWatchedEpisode.Id,
Name: progress.lastWatchedEpisode.Name,
IndexNumber: progress.lastWatchedEpisode.IndexNumber,
ParentIndexNumber: progress.lastWatchedEpisode.ParentIndexNumber,
UserData: progress.lastWatchedEpisode.UserData,
ImageTags: progress.lastWatchedEpisode.ImageTags
} : null,
// Binary progress data for instant rendering (compact storage)
binaryProgress: progress.binaryProgress || {}
// Episodes array completely removed - will be fetched on-demand
}));
}
function optimizeMovieDataForStorage(movieDataArray) {
// Return optimized movie data
return movieDataArray.map(movie => ({
Id: movie.Id,
Name: movie.Name,
BackdropImageTags: movie.BackdropImageTags,
ImageTags: movie.ImageTags,
PremiereDate: movie.PremiereDate,
RunTimeTicks: movie.RunTimeTicks,
ProductionYear: movie.ProductionYear,
UserData: {
IsFavorite: movie.UserData.IsFavorite,
LastPlayedDate: movie.UserData.LastPlayedDate,
PlayCount: movie.UserData.PlayCount,
Played: movie.UserData.Played
}
}));
}
// Optimize watchlist data for localStorage storage
function optimizeWatchlistDataForStorage(watchlistDataArray) {
return watchlistDataArray.map(item => ({
Id: item.Id,
Type: item.Type,
MediaType: item.MediaType,
Name: item.Name,
SeriesId: item.SeriesId,
SeriesName: item.SeriesName,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags: item.ParentBackdropImageTags,
ParentThumbImageTag: item.ParentPrimaryImageTags,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
ImageTags: item.ImageTags,
BackdropImageTags: item.BackdropImageTags,
PremiereDate: item.PremiereDate,
ProductionYear: item.ProductionYear,
UserData: {
IsFavorite: item.UserData.IsFavorite,
Played: item.UserData.Played,
LastPlayedDate: item.UserData.LastPlayedDate,
PlayCount: item.UserData.PlayCount
}
}));
}
// Add refresh button to tab headers
function addRefreshButtons() {
const watchlistSection = getWatchlistSection();
if (!watchlistSection) return;
// Progress tab refresh button and sort button
const progressStats = watchlistSection.querySelector('.progress-header-stats-container');
if (progressStats) {
const refreshBtn = createRefreshButton('progress', 'Series Progress');
// Add sort button next to refresh button
const sortBtn = createSortButton();
progressStats.appendChild(sortBtn);
progressStats.appendChild(refreshBtn);
}
// History tab refresh button
const historyStats = watchlistSection.querySelector('.movie-history-stats-container');
if (historyStats) {
// Add sort button first
const sortBtn = createMovieSortButton();
historyStats.appendChild(sortBtn);
const refreshBtn = createRefreshButton('movies', 'Movie History');
historyStats.appendChild(refreshBtn);
}
// Watchlist tab refresh button
const watchlistHeaderRight = watchlistSection.querySelector('.watchlist-header-right');
if (watchlistHeaderRight) {
const importExportBtn = createImportExportButton();
watchlistHeaderRight.appendChild(importExportBtn);
const refreshBtn = createRefreshButton('watchlist', 'Watchlist');
watchlistHeaderRight.appendChild(refreshBtn);
}
}
// Create a refresh button element
function createRefreshButton(cacheName, label) {
const refreshBtn = document.createElement('button');
refreshBtn.className = 'paper-icon-button-light emby-button ';
refreshBtn.innerHTML = '';
refreshBtn.title = `Refresh ${label} data from server`;
refreshBtn.onclick = async () => {
refreshBtn.disabled = true;
refreshBtn.innerHTML = '';
try {
// Clear localStorage cache
if (cacheName === 'progress') {
localStorageCache.clear(`progress`);
// Clear in-memory cache
progressCache.data = [];
progressCache.allDataLoaded = false;
progressCache.totalPages = 0;
tabStates.progress.isDataFetched = false;
} else if (cacheName === 'movies') {
localStorageCache.clear(`movies`);
// Clear in-memory cache
movieCache.data = [];
movieCache.allDataLoaded = false;
movieCache.totalPages = 0;
tabStates.history.isDataFetched = false;
} else if (cacheName === 'watchlist') {
// Clear all watchlist sections
const sections = ['movies', 'series', 'seasons', 'episodes'];
sections.forEach(section => {
localStorageCache.clear(`watchlist_${section}`);
watchlistCache[section].data = [];
});
}
// Re-fetch data
if (cacheName === 'progress') {
await initProgressTab();
} else if (cacheName === 'movies') {
await initHistoryTab();
} else if (cacheName === 'watchlist') {
await initWatchlistTab();
}
// Show success feedback with checkmark
refreshBtn.innerHTML = '';
setTimeout(() => {
refreshBtn.innerHTML = '';
refreshBtn.disabled = false;
}, 2000);
} catch (error) {
ERR('Failed to refresh cache:', error);
refreshBtn.innerHTML = '';
setTimeout(() => {
refreshBtn.innerHTML = '';
refreshBtn.disabled = false;
}, 3000);
}
};
return refreshBtn;
}
// Create import/export button element
function createImportExportButton() {
const btn = document.createElement('button');
btn.className = 'layout-toggle-btn';
btn.innerHTML = '';
btn.title = 'Import/Export Watchlist';
btn.onclick = () => {
showImportExportModal();
};
return btn;
}
// Create sort button element
function createSortButton() {
const sortBtn = document.createElement('button');
sortBtn.className = 'sort-button';
sortBtn.id = 'progress-sort-btn';
// Get current sort order
const currentSort = getCurrentSortOrder();
const currentLabel = SORT_OPTIONS[currentSort]?.label || 'Last Watched';
sortBtn.innerHTML = `
${currentLabel}
`;
sortBtn.title = 'Sort series by different criteria';
sortBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
showSortModal();
};
// Update the label with direction indicator after creation
updateSortButtonLabel(currentSort);
return sortBtn;
}
// Create movie sort button element
function createMovieSortButton() {
const sortBtn = document.createElement('button');
sortBtn.className = 'sort-button';
sortBtn.id = 'movie-sort-btn';
// Get current sort order
const currentSort = getCurrentMovieSortOrder();
const currentLabel = MOVIE_SORT_OPTIONS[currentSort]?.label || 'Last Watched';
sortBtn.innerHTML = `
${currentLabel}
`;
sortBtn.title = 'Sort movies by different criteria';
sortBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
showMovieSortModal();
};
// Update the label with direction indicator after creation
updateMovieSortButtonLabel(currentSort);
return sortBtn;
}
// Show sort options modal
function showSortModal() {
// Create modal content
const sortOptionsContent = `
`;
}
// 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": `MediaBrowser Token=\"${token}\"` } });
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": `MediaBrowser Token=\"${token}\"` } });
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": `MediaBrowser Token=\"${token}\"` } });
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 url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=Id`;
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
const data = await res.json();
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 cache
const sections = ['movies', 'series', 'seasons', 'episodes'];
sections.forEach(section => {
localStorageCache.clear(`watchlist_${section}`);
watchlistCache[section].data = [];
});
// Refresh watchlist
await initWatchlistTab();
renderWatchlistContent();
alert(`Watchlist cleared: ${cleared} item(s) removed.`);
}
// Update watchlist cache when an item is toggled
async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
try {
// Determine which cache section this item belongs to
let sectionName;
switch (itemType) {
case 'Movie':
sectionName = 'movies';
break;
case 'Series':
sectionName = 'series';
break;
case 'Season':
sectionName = 'seasons';
break;
case 'Episode':
sectionName = 'episodes';
break;
default:
LOG('Unknown item type for cache update:', itemType);
return;
}
if (isAdded) {
// Item was added to watchlist - fetch the item and add to cache
const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
if (item) {
// Add to cache if not already present
const existingIndex = watchlistCache[sectionName].data.findIndex(cachedItem => cachedItem.Id === itemId);
if (existingIndex === -1) {
watchlistCache[sectionName].data.push(item);
// Sort by release date descending (newest first)
watchlistCache[sectionName].data.sort((a, b) => {
const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
return dateB - dateA;
});
// Update localStorage cache
const optimizedData = optimizeWatchlistDataForStorage(watchlistCache[sectionName].data);
localStorageCache.set(`watchlist_${sectionName}`, optimizedData, ApiClient._currentUser.Id, WATCHLIST_CACHE_TTL);
LOG(`Added ${itemType} to watchlist cache: ${item.Name}`);
}
}
} else {
// Item was removed from watchlist - remove from cache
const existingIndex = watchlistCache[sectionName].data.findIndex(cachedItem => cachedItem.Id === itemId);
if (existingIndex !== -1) {
const removedItem = watchlistCache[sectionName].data.splice(existingIndex, 1)[0];
// Update localStorage cache
const optimizedData = optimizeWatchlistDataForStorage(watchlistCache[sectionName].data);
localStorageCache.set(`watchlist_${sectionName}`, optimizedData, ApiClient._currentUser.Id, WATCHLIST_CACHE_TTL);
LOG(`Removed ${itemType} from watchlist cache: ${removedItem.Name}`);
}
}
} catch (err) {
ERR('Error updating watchlist cache:', err);
}
}
// Initialize monitoring to remove watched items from watchlist (playback + manual)
function initializePlaybackMonitoring() {
if (playbackMonitorInitialized) {
return;
}
const maxRetries = 10;
let retries = 0;
// Use WebSocket to listen for UserDataChanged messages instead of intercepting fetch
// This is more reliable and doesn't interfere with fetch requests
function setupWebSocketMonitoring() {
try {
// Grab the actual socket
const socket = (window.ApiClient && (window.ApiClient.webSocket || window.ApiClient._webSocket)) || null;
if (!socket) {
if (retries >= maxRetries) {
ERR('Max retries reached, giving up on WebSocket monitoring');
return false;
}
retries++;
setTimeout(() => {
if (!playbackMonitorInitialized) {
setupWebSocketMonitoring();
}
}, 1000);
return;
}
// Store original handler if it exists
const originalHandler = socket.onmessage;
// Hook into onmessage
socket.onmessage = function(event) {
try {
// Pass it through so Jellyfin still works normally
if (originalHandler) {
originalHandler.call(this, event);
}
const messageData = event.Data || event.data;
const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
// Check if this is a UserDataChanged message
if (data.MessageType === 'UserDataChanged' && data.Data && data.Data.UserDataList && data.Data.UserDataList.length > 0) {
const userData = data.Data.UserDataList[0];
if (userData.ItemId) {
LOG(`Detected UserDataChanged for item: ${userData.ItemId}, Played: ${userData.Played}`);
// Handle the watched status change (works for both played and unplayed)
handleItemWatchedStatusChange(userData.ItemId, userData.Played, userData.Likes).catch(err => {
ERR('Error handling UserDataChanged event:', err);
});
}
}
} catch (err) {
// Log parse errors but don't break the original handler
WARN('WebSocket message parse error:', err);
}
};
playbackMonitorInitialized = true;
LOG('Playback and watch status monitoring initialized via WebSocket');
return true;
} catch (err) {
ERR('Error setting up WebSocket monitoring:', err);
return false;
}
}
//setupWebSocketMonitoring();
// Try to set up immediately
if (!setupWebSocketMonitoring()) {
// If WebSocket isn't available yet, retry after a short delay
// This can happen if the page loads before the WebSocket connection is established
setTimeout(() => {
if (!playbackMonitorInitialized) {
setupWebSocketMonitoring();
}
}, 1000);
}
}
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');
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": `MediaBrowser Token=\"${token}\"` } });
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', 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');
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', 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;
// Check all watchlist sections
const sections = ['movies', 'series', 'seasons', 'episodes'];
for (const section of sections) {
const items = watchlistCache[section].data;
const playedItems = items.filter(item => item.UserData && item.UserData.Played);
if (playedItems.length > 0) {
LOG(`Found ${playedItems.length} played items in ${section} section`);
// Remove each played item
for (const item of playedItems) {
await removeItemFromWatchlist(item.Id, item.Type);
removedCount++;
}
}
}
if (removedCount > 0) {
LOG(`Sync complete: removed ${removedCount} played items from watchlist`);
} 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 API
async function checkIfItemInWatchlist(itemId) {
try {
// First check if we have the item in our cache (faster)
const sections = ['movies', 'series', 'seasons', 'episodes'];
for (const section of sections) {
if (watchlistCache[section].data.some(item => item.Id === itemId)) {
return true;
}
}
// If not in cache, check via API
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
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&UserId=${userId}&Recursive=true&Filters=IsPlayed&Fields=UserData,ProviderIds&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
try {
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
const data = await res.json();
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 = `
`;
return card;
}
async function renderHistoryContent() {
LOG('Rendering Movie History content');
const watchlistSection = getWatchlistSection();
const historyTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="history"]') : null;
const paginatedContainer = historyTab ? historyTab.querySelector('.paginated-container') : null;
const container = paginatedContainer ? paginatedContainer.querySelector('.movie-history-grid') : null;
const emptyMessage = historyTab ? historyTab.querySelector('.movie-history-empty-message') : null;
const movieCount = getElementByIdSafe('movie-count');
if (!container) {
WARN("Movie history container not found");
return;
}
try {
// Check if we're currently fetching data or data hasn't been fetched yet
if (tabStates.history.isFetching || !tabStates.history.isDataFetched) {
hideEmptyHistoryMessage();
container.innerHTML = '
Loading movie history...
';
return;
}
// Get current page and search terms from URL
const params = getUrlParams();
const currentPage = (params.pageTab === 'history') ?
(params.page || 1) :
(movieCache.currentPage || 1);
const movieSearchTerm = params.movieSearch || '';
// Get current sort order and apply sorting to movie data
const currentSort = getCurrentMovieSortOrder();
const sortedMovies = applyMovieSorting(movieCache.data, currentSort);
// Update pagination with sorted data
moviePagination.updateCache(sortedMovies);
// Get paginated movie data using the new pagination system
const { pageData: movies, totalPages } = moviePagination.getCachedPage(currentPage);
const searchInput = getElementByIdSafe('movie-search');
if (searchInput && params.pageTab === 'history') {
searchInput.value = movieSearchTerm;
}
if (totalPages > 1) {
renderPaginationControls(currentPage, totalPages, 'history');
}
// Check if we should skip rendering
if (shouldSkipRendering('history', currentPage, movieSearchTerm, container, movies)) {
LOG(`Skipping history render - same page (${currentPage}), search ("${movieSearchTerm}"), and content`);
// Still need to ensure pagination controls are rendered
if (totalPages > 1) {
renderPaginationControls(currentPage, totalPages, 'history');
}
return;
}
// Restore search state if URL has search parameter
if (movieSearchTerm && movieSearchTerm !== movieCache.searchTerm) {
const searchInput = getElementByIdSafe('movie-search');
const clearBtn = getElementByIdSafe('movie-search-clear');
if (searchInput) {
searchInput.value = movieSearchTerm;
updateMovieSearchResults(movieSearchTerm);
if (clearBtn) clearBtn.style.display = 'flex';
}
}
// Always use full, non-filtered movie data for stats
const totalMovies = movieCache.data.length;
// Update movie count
if (movieCount) {
movieCount.textContent = `${totalMovies} Watched`;
}
if (totalMovies === 0) {
// Show empty message
container.innerHTML = '';
if (emptyMessage) {
emptyMessage.style.display = 'block';
}
LOG('No watched movies found, showing empty state');
return;
}
// Hide empty message and clear container
if (emptyMessage) {
emptyMessage.style.display = 'none';
}
container.innerHTML = '';
// Create movie cards
movies.forEach(movie => {
const movieCard = createMovieCard(movie);
container.appendChild(movieCard);
});
// Update cache currentPage
movieCache.currentPage = currentPage;
// Update tab state after successful render
updateTabState('history', currentPage, movieSearchTerm);
LOG(`Rendered ${movies.length} watched movies for page ${currentPage} of ${totalPages}`);
} catch (err) {
ERR('Error rendering movie history content:', err);
// Clear container and show empty message on error
container.innerHTML = '';
clearTabState('history');
if (emptyMessage) {
emptyMessage.style.display = 'block';
}
}
}
async function renderStatisticsContent() {
LOG('Rendering Statistics content');
try {
// Check if both data sources are fully loaded
const progressDataReady = progressCache.allDataLoaded;
const historyDataReady = movieCache.allDataLoaded;
const watchlistSection = getWatchlistSection();
const statisticsTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="statistics"]') : null;
if (!statisticsTab) return;
if (!progressDataReady || !historyDataReady) {
// Set data-ready="false" to show loading container
statisticsTab.setAttribute('data-ready', 'false');
return;
}
// Both data sources are ready, set data-ready="true" to show content
statisticsTab.setAttribute('data-ready', 'true');
// Both data sources are ready, proceed with statistics rendering
// Ensure we have all progress data loaded
await fetchAllProgressData(true);
// Update statistics with all progress data
await updateProgressStatistics(progressCache.data);
LOG('Statistics content rendered successfully');
} catch (err) {
ERR('Error rendering statistics content:', err);
// Set data-ready="false" on error
if (statisticsTab) {
statisticsTab.setAttribute('data-ready', 'false');
}
}
}
function renderPaginationControls(currentPage, totalPages, tabName = 'progress') {
const watchlistSection = getWatchlistSection();
if (!watchlistSection) return;
// Check if pagination already exists for this tab
const tabContent = watchlistSection.querySelector(`div[data-tab="${tabName}"]`);
const paginatedContainer = tabContent ? tabContent.querySelector('.paginated-container') : null;
if (!paginatedContainer) return;
// Check for existing pagination controls
const existingTopPagination = paginatedContainer.querySelector('.pagination-top');
const existingBottomPagination = paginatedContainer.querySelector('.pagination-bottom');
// If totalPages <= 1, remove existing pagination and return
if (totalPages <= 1) {
if (existingTopPagination) existingTopPagination.remove();
if (existingBottomPagination) existingBottomPagination.remove();
return; // Don't show pagination for single page
}
// Check if pagination already exists and is correct
// If pagination exists and shows the same page, just update active states
if (existingTopPagination && existingBottomPagination) {
const currentPageFromPagination = existingTopPagination.querySelector('.pagination-info')?.textContent;
const expectedPageInfo = `Page ${currentPage} of ${totalPages}`;
if (currentPageFromPagination === expectedPageInfo) {
// Just update active states without recreating
updatePaginationActiveStates(existingTopPagination, currentPage, totalPages);
updatePaginationActiveStates(existingBottomPagination, currentPage, totalPages);
return;
}
}
// Remove existing pagination for this specific tab only
if (existingTopPagination) existingTopPagination.remove();
if (existingBottomPagination) existingBottomPagination.remove();
// Create pagination container
const paginationContainer = document.createElement('div');
paginationContainer.className = 'pagination';
paginationContainer.innerHTML = `
Page ${currentPage} of ${totalPages}
${generatePageNumbers(currentPage, totalPages)}
`;
// Function to add event listeners to pagination container
const addPaginationListeners = (container) => {
container.addEventListener('click', (e) => {
const button = e.target.closest('.pagination-btn');
if (!button || button.classList.contains('disabled')) return;
const page = parseInt(button.dataset.page);
LOG(`Pagination clicked: page ${page}, current page ${currentPage}, tab ${tabName}`);
if (page && page !== currentPage) {
// Get search terms based on tab
const searchTerm = tabName === 'progress' ? progressCache.searchTerm : '';
const movieSearchTerm = tabName === 'history' ? movieCache.searchTerm : '';
LOG(`Navigating to page ${page} with search terms: progress="${searchTerm}", movie="${movieSearchTerm}"`);
updateUrlWithSearch(tabName, page, searchTerm, movieSearchTerm);
if (tabName === 'history') {
renderHistoryContent();
} else {
renderProgressContent();
}
}
});
};
// Add event listeners to the original container
addPaginationListeners(paginationContainer);
// Insert pagination controls at both top and bottom of paginated container
if (paginatedContainer) {
// Top pagination
const topPagination = paginationContainer.cloneNode(true);
topPagination.classList.add('pagination-top');
addPaginationListeners(topPagination); // Add event listeners to cloned container
paginatedContainer.insertBefore(topPagination, paginatedContainer.firstChild);
// Bottom pagination
const bottomPagination = paginationContainer.cloneNode(true);
bottomPagination.classList.add('pagination-bottom');
addPaginationListeners(bottomPagination); // Add event listeners to cloned container
paginatedContainer.appendChild(bottomPagination);
}
}
function updatePaginationActiveStates(paginationElement, currentPage, totalPages) {
// Update page info
const pageInfo = paginationElement.querySelector('.pagination-info');
if (pageInfo) {
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
}
// Update button states
const buttons = paginationElement.querySelectorAll('.pagination-btn');
buttons.forEach(button => {
const page = parseInt(button.dataset.page);
button.classList.remove('active', 'disabled');
if (page === currentPage) {
button.classList.add('active');
} else if ((page === 1 || page === currentPage - 1) && currentPage === 1) {
button.classList.add('disabled');
} else if ((page === totalPages || page === currentPage + 1) && currentPage === totalPages) {
button.classList.add('disabled');
}
});
}
function generatePageNumbers(currentPage, totalPages) {
const maxVisible = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
// Adjust start if we're near the end
if (endPage - startPage + 1 < maxVisible) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
let pages = '';
// Add first page and ellipsis if needed
if (startPage > 1) {
pages += ``;
if (startPage > 2) {
pages += '...';
}
}
// Add visible pages
for (let i = startPage; i <= endPage; i++) {
pages += ``;
}
// Add last page and ellipsis if needed
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages += '...';
}
pages += ``;
}
return pages;
}
async function createProgressCard(progress) {
const { series, watchedCount, totalEpisodes, remainingCount, percentage, watchedRuntime, remainingRuntime, lastWatchedEpisode } = progress;
const card = document.createElement('div');
card.className = 'progress-card';
card.dataset.seriesId = series.Id;
// Add completed class for 100% shows
if (percentage === 100) {
card.classList.add('progress-card-completed');
}
// Get series image URL
const imageUrl = window.innerWidth > 768 ?
series.ImageTags && series.ImageTags.Primary
? `${ApiClient._serverAddress}/Items/${series.Id}/Images/Primary?maxHeight=200&maxWidth=140&tag=${series.ImageTags.Primary}`
: ''
: series.ImageTags && series.ImageTags.Banner
? `${ApiClient._serverAddress}/Items/${series.Id}/Images/Banner?maxWidth=300&height=60&tag=${series.ImageTags.Banner}`
: '';
// Format last watched info (only if we have episode data)
let lastWatchedInfo = '';
if (lastWatchedEpisode) {
const timeAgo = formatTimeAgo(lastWatchedEpisode.UserData.LastPlayedDate);
const formattedDate = formatLastWatchedDate(lastWatchedEpisode.UserData.LastPlayedDate);
const episodeInfo = `${lastWatchedEpisode.ParentIndexNumber}x${lastWatchedEpisode.IndexNumber.toString().padStart(2, '0')} "${lastWatchedEpisode.Name}"`;
lastWatchedInfo = `Last watched ${episodeInfo} ${timeAgo} on ${formattedDate}.`;
}
card.innerHTML = `
${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&EnableImageTypes=Primary`;
const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
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": `MediaBrowser Token="${token}"`,
"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": `MediaBrowser Token="${token}"`,
"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 cached data
async function renderCardsFromCache(container, sectionName, type) {
if (!container) {
WARN("Container not found for type:", type);
return { type, itemCount: 0 };
}
const items = watchlistCache[sectionName].data;
// Hide section if no items
if (!items || items.length === 0) {
container.style.display = 'none';
return { type, itemCount: 0 };
}
// Check if content has changed by comparing data-ids
if (compareDataIds(container, items)) {
LOG(`Skipping ${type} render - content unchanged (${items.length} items)`);
container.style.display = '';
return { type, itemCount: items.length };
}
// Show section and use cardBuilder to create scrollable container
container.style.display = '';
// Use cardBuilder to create scrollable container
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
if (activeTab === 'progress') {
renderProgressContent();
} else if (activeTab === 'watchlist') {
renderWatchlistContent();
} else if (activeTab === 'history') {
renderHistoryContent();
} else if (activeTab === 'statistics') {
renderStatisticsContent();
}
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
const showAllBtn = getElementByIdSafe('show-all-shows-btn');
if (showAllBtn) {
showAllBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showAllShowsModal();
});
}
// 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 {
const watchlistSection = getWatchlistSection();
if (!watchlistSection) {
ERR('Watchlist section not found');
return;
}
const watchlistSections = ['.watchlist-movies', '.watchlist-series', '.watchlist-seasons', '.watchlist-episodes'];
// Check if we're currently fetching data or data hasn't been fetched yet
if (!tabStates.watchlist.isDataFetched) {
//Hide empty message
hideEmptyWatchlistMessage();
watchlistSections.forEach(section => {
const container = watchlistSection.querySelector(section);
if (container) {
container.innerHTML = '';
}
});
// Show loading state for watchlist tab
const container = watchlistSection.querySelector('.watchlist-movies');
if (container) {
container.innerHTML = '
Loading watchlist...
';
container.style.display = '';
}
return;
}
// Render all sections using cached data
const results = await Promise.all([
renderCardsFromCache(watchlistSection.querySelector('.watchlist-movies'), 'movies', 'Movie'),
renderCardsFromCache(watchlistSection.querySelector('.watchlist-series'), 'series', 'Series'),
renderCardsFromCache(watchlistSection.querySelector('.watchlist-seasons'), 'seasons', 'Season'),
renderCardsFromCache(watchlistSection.querySelector('.watchlist-episodes'), 'episodes', 'Episode')
]);
// Update watchlist stats in header
updateWatchlistHeaderStats();
// Check if all sections are empty
const totalItems = results.reduce((sum, result) => sum + result.itemCount, 0);
if (totalItems === 0) {
// Show empty state message
showEmptyWatchlistMessage();
} else {
// Hide empty state message if it exists
hideEmptyWatchlistMessage();
}
} catch (err) {
ERR('Error rendering watchlist cards:', err);
}
}
// Function to check if watchlist section exists and render content
async function renderWatchlist() {
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 (isNewSection || isRecreatedSection) {
LOG(`Watchlist section ${isNewSection ? 'found' : 'recreated'}, 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 content matches cache expectations
function isWatchlistContentValid(watchlistSection) {
try {
// Check if all expected containers exist
const moviesContainer = watchlistSection.querySelector('.watchlist-movies');
const seriesContainer = watchlistSection.querySelector('.watchlist-series');
const seasonsContainer = watchlistSection.querySelector('.watchlist-seasons');
const episodesContainer = watchlistSection.querySelector('.watchlist-episodes');
if (!moviesContainer || !seriesContainer || !seasonsContainer || !episodesContainer) {
LOG('Watchlist containers missing, content invalid');
return false;
}
const moviesItemsContainer = moviesContainer.querySelector('.itemsContainer');
const seriesItemsContainer = seriesContainer.querySelector('.itemsContainer');
const seasonsItemsContainer = seasonsContainer.querySelector('.itemsContainer');
const episodesItemsContainer = episodesContainer.querySelector('.itemsContainer');
// Check if content matches cache expectations
const expectedMovies = watchlistCache.movies.data.length;
const expectedSeries = watchlistCache.series.data.length;
const expectedSeasons = watchlistCache.seasons.data.length;
const expectedEpisodes = watchlistCache.episodes.data.length;
const actualMovies = moviesItemsContainer ? moviesItemsContainer.children.length : 0;
const actualSeries = seriesItemsContainer ? seriesItemsContainer.children.length : 0;
const actualSeasons = seasonsItemsContainer ? seasonsItemsContainer.children.length : 0;
const actualEpisodes = episodesItemsContainer ? episodesItemsContainer.children.length : 0;
const contentMatches = actualMovies === expectedMovies &&
actualSeries === expectedSeries &&
actualSeasons === expectedSeasons &&
actualEpisodes === expectedEpisodes;
if (!contentMatches) {
LOG(`Watchlist content mismatch - Expected: ${expectedMovies}M/${expectedSeries}S/${expectedSeasons}Se/${expectedEpisodes}E, Actual: ${actualMovies}M/${actualSeries}S/${actualSeasons}Se/${actualEpisodes}E`);
watchlistSection.dataset.htmlRendered = 'false';
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;
}
}
// Set up observer to watch for home page navigation
function setupWatchlistSectionObserver() {
// Check immediately in case we're already on home page
if (window.location.href.includes('#/home')) {
renderWatchlist();
}
// Create a MutationObserver to watch for empty watchlist containers
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Check for added nodes
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this is a library page that became visible
if (node.classList && node.classList.contains('homePage') && !node.classList.contains('hide')) {
checkForEmptyWatchlist();
}
// Also check any library pages within the added node
const libraryPages = node.querySelectorAll ? node.querySelectorAll('.homePage:not(.hide)') : [];
libraryPages.forEach(libraryPage => {
checkForEmptyWatchlist();
});
}
});
}
// Check for attribute changes (like removing 'hide' class)
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.classList && target.classList.contains('homePage') && !target.classList.contains('hide')) {
checkForEmptyWatchlist();
}
}
});
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
// Function to check for empty watchlist container
function checkForEmptyWatchlist() {
const visibleWatchlistSections = document.querySelectorAll('.libraryPage:not(.hide) .sections.watchlist');
visibleWatchlistSections.forEach(section => {
if (section.children.length === 0) {
LOG('Empty watchlist container found, initializing...');
renderWatchlist();
}
});
}
}
// 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 cache data
function updateWatchlistHeaderStats() {
const showsCount = watchlistCache.series.data.length;
const seasonsCount = watchlistCache.seasons.data.length;
const episodesCount = watchlistCache.episodes.data.length;
const moviesCount = watchlistCache.movies.data.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';
}
}
}
// 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;
}
// Fetch the tab index as we do in addCustomMenuLink
try {
const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(),
},
});
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) {
ERR('Failed to fetch watchlist tab index:', err);
}
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);
}
}
}
// Register onViewPage handler to call setActiveTab
if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) {
window.KefinTweaksUtils.onViewPage(async (view, element, hash) => {
LOG('onViewPage handler triggered for view:', view);
renderWatchlist();
// Check if we're on the watchlist tab
const watchlistTabIndex = await getWatchlistTabIndex();
if (watchlistTabIndex !== null && hash) {
// Get current tab from URL
const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
const urlParams = new URLSearchParams(hashParams);
const currentTab = urlParams.get('tab');
const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0;
if (currentTabIndex === watchlistTabIndex) {
LOG('On watchlist tab, enhancing progress bars with episode details');
// Small delay to let initial render complete
setTimeout(() => {
//fetchEpisodesAndEnhanceProgressBar();
}, 100);
}
}
}, {
pages: ['home', 'home.html'] // Only trigger for home page where watchlist tabs are
});
LOG('Registered onViewPage handler for setActiveTab');
} else {
WARN('KefinTweaksUtils.onViewPage not available');
}
function addCustomMenuLink() {
LOG('Adding custom menu link for Watchlist tab');
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(),
},
})
.then((r) => r.json())
.then((data) => {
console.log(data);
data.forEach((tab, index) => {
if (tab.ContentHtml.indexOf('sections watchlist') !== -1) {
const watchlistTabIndex = index + 2;
_watchlistTabIndex = watchlistTabIndex; // Store the tab index
let homePageSuffix = '.html';
if (ApiClient._serverInfo.Version?.split('.')[1] > 10) {
homePageSuffix = '';
}
window.KefinTweaksUtils.addCustomMenuLink(
'Watchlist',
'bookmark',
`#/home${homePageSuffix}?tab=${watchlistTabIndex}`
);
LOG('Added custom menu link to side menu for Watchlist tab');
}
});
})
.catch(console.error);
}
}
addCustomMenuLink();
// Initialize playback monitoring
initializePlaybackMonitoring();
/************ Watchlist Button Observer ************/
// Function to add watchlist button to a card overlay container
function addWatchlistButton(overlayContainer) {
// Check if watchlist button already exists
if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) {
return;
}
// Find the card parent to get the item ID
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;
}
// Check if card has data-type attribute - if not, don't add watchlist button
const itemType = card.getAttribute('data-type');
if (!itemType) {
LOG('Card has no data-type, skipping watchlist button');
return;
}
// Find the .cardOverlayButton-br container
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (!buttonContainer) {
WARN('Could not find .cardOverlayButton-br container');
return;
}
// Create watchlist button
const 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);
watchlistButton.setAttribute('data-active', 'false');
watchlistButton.title = 'Add to Watchlist';
// Create the bookmark icon
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
watchlistIcon.setAttribute('aria-hidden', 'true');
watchlistButton.appendChild(watchlistIcon);
// 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 icon and title based on state
const isActive = watchlistButton.dataset.active === 'true';
// Icon state is handled by CSS class, no need to change textContent
watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
// Update watchlist cache immediately
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
});
// Check if item type is supported for watchlist
if (itemType !== "Movie" && itemType !== "Series" && itemType !== "Season" && itemType !== "Episode") {
return;
}
if (!ApiClient._loggedIn) {
LOG('User is not logged in, skipping watchlist button');
return;
}
// Check item's current watchlist status from cache only (no server fetch)
// Map itemType to section name
let sectionName;
switch (itemType) {
case 'Movie':
sectionName = 'movies';
break;
case 'Series':
sectionName = 'series';
break;
case 'Season':
sectionName = 'seasons';
break;
case 'Episode':
sectionName = 'episodes';
break;
default:
// Unknown type, skip check
break;
}
if (sectionName) {
// First check in-memory cache
let isInWatchlist = false;
if (watchlistCache[sectionName] && watchlistCache[sectionName].data) {
isInWatchlist = watchlistCache[sectionName].data.some(item => item.Id === itemId);
}
// If not in memory cache, check localStorage cache
if (!isInWatchlist) {
const cachedData = localStorageCache.get(`watchlist_${sectionName}`);
if (cachedData && Array.isArray(cachedData)) {
isInWatchlist = cachedData.some(item => item.Id === itemId);
}
}
// Set button state if item is in watchlist
if (isInWatchlist) {
watchlistButton.dataset.active = 'true';
watchlistButton.title = 'Remove from Watchlist';
}
}
// Add the watchlist button to the button container, right before the play state button if it exists
const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]');
if (playStateButton) {
buttonContainer.insertBefore(watchlistButton, playStateButton);
} else {
buttonContainer.appendChild(watchlistButton);
}
}
// 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);
}
});
}
// Set up MutationObserver to watch for new overlay containers
function setupWatchlistButtonObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is an overlay container
if (node.classList && node.classList.contains('cardOverlayContainer')) {
const buttonContainer = node.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addWatchlistButton(node);
}
}
// Check for overlay containers within the added node
const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
if (overlayContainers && overlayContainers.length > 0) {
overlayContainers.forEach((overlayContainer) => {
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addWatchlistButton(overlayContainer);
}
});
}
}
});
}
});
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Process any existing overlay containers
processExistingOverlayContainers();
}
// Initialize the observer
setupWatchlistButtonObserver();
/************ Item Detail Page Observer ************/
// Function to add watchlist button to item detail page
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';
// Add the content wrapper and icon span inside the button
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);
// Get item ID from URL
const itemId = 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;
// Update title based on state
const isActive = watchlistIcon.dataset.active === 'true';
watchlistIcon.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
// Update watchlist cache immediately
// Get item type from the item data we already have
const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
if (item) {
await updateWatchlistCacheOnToggle(itemId, item.Type, isActive);
}
});
if (item) {
// Set initial state based on current watchlist status
if (item.UserData && item.UserData.Likes) {
watchlistIcon.dataset.active = 'true';
watchlistIcon.title = 'Remove from Watchlist';
}
} else {
// Get item data to check if it should be shown and current state
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") {
watchlistIcon.style.display = "none";
}
if (item.UserData && item.UserData.Played) {
watchlistIcon.style.display = "none";
}
// Set initial state based on current watchlist status
if (item.UserData && item.UserData.Likes) {
watchlistIcon.dataset.active = 'true';
watchlistIcon.title = 'Remove from Watchlist';
}
}).catch(err => {
ERR('Error fetching item data:', err);
});
}
// Add button after trailer button
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) => {
// Await the item promise to get the actual item data
const item = await itemPromise;
// Small delay to ensure details DOM is ready
setTimeout(() => {
const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)');
if (!visibleItemDetailPage) {
return;
}
if (visibleItemDetailPage.dataset.watchlistButtonAdded === 'true') {
return;
}
// Check if watchlist button already exists
const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon');
if (existingButton) {
return;
}
// Check if item is provided and if item type is supported for watchlist
if (item && (item.Type === "Movie" || item.Type === "Series" || item.Type === "Season" || item.Type === "Episode")) {
visibleItemDetailPage.dataset.watchlistButtonAdded = 'true';
addDetailPageWatchlistButton(item);
}
}, 100);
}, {
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 observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is a slide
if (node.classList && node.classList.contains('slide')) {
addSlideWatchlistButton(node);
}
// Check for slides within the added node
const slides = node.querySelectorAll && node.querySelectorAll('.slide');
if (slides && slides.length > 0) {
slides.forEach((slide) => {
addSlideWatchlistButton(slide);
});
}
}
});
}
});
});
// Check if slides container already exists
const slidesContainer = document.querySelector('#slides-container');
if (slidesContainer) {
// Process existing slides
const existingSlides = slidesContainer.querySelectorAll('.slide');
existingSlides.forEach((slide) => {
addSlideWatchlistButton(slide);
});
// Start observing the slides container
observer.observe(slidesContainer, {
childList: true,
subtree: true
});
}
// Also watch for slides container to be added
const containerObserver = new MutationObserver(() => {
const slidesContainer = document.querySelector('#slides-container');
if (slidesContainer) {
// Process existing slides
const existingSlides = slidesContainer.querySelectorAll('.slide');
existingSlides.forEach((slide) => {
addSlideWatchlistButton(slide);
});
// Start observing the slides container
observer.observe(slidesContainer, {
childList: true,
subtree: true
});
// Disconnect the container observer since we've found it
containerObserver.disconnect();
}
});
// Start observing the entire document body for the slides container
containerObserver.observe(document.body, {
childList: true,
subtree: true
});
}
// 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 containers = [
{ name: 'Movies', el: watchlistSection.querySelector('.watchlist-movies') },
{ name: 'Series', el: watchlistSection.querySelector('.watchlist-series') },
{ name: 'Seasons', el: watchlistSection.querySelector('.watchlist-seasons') },
{ name: 'Episodes', el: watchlistSection.querySelector('.watchlist-episodes') }
];
containers.forEach(({ name, el }) => {
if (el) {
const dataIds = Array.from(el.querySelectorAll('[data-id]'))
.map(card => card.getAttribute('data-id'))
.sort();
const childCount = el.children.length;
LOG(`${name}: ${childCount} children, data-ids=[${dataIds.join(', ')}]`);
} else {
LOG(`${name}: container not found`);
}
});
};
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": `MediaBrowser Token=\"${token}\"` } });
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": `MediaBrowser Token=\"${token}\"` }
})
.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');
})();