// KefinTweaks Series Info // Adds series and season information to details pages // Requires: utils.js module to be loaded before this script (function() { 'use strict'; const LOG = (...args) => console.log('[KefinTweaks SeriesInfo]', ...args); const WARN = (...args) => console.warn('[KefinTweaks SeriesInfo]', ...args); const ERR = (...args) => console.error('[KefinTweaks SeriesInfo]', ...args); const state = { use24HourTimeFormat: false, } /** * Parses a runtime string (e.g., "42m", "1h 5m") into total minutes * @param {string} runtimeStr - Runtime string like "42m" or "1h 5m" * @returns {number} Total minutes, or 0 if parsing fails */ function parseRuntimeString(runtimeStr) { if (!runtimeStr || typeof runtimeStr !== 'string') { return 0; } let totalMinutes = 0; // Match hours: "1h" or "1 h" const hourMatch = runtimeStr.match(/(\d+)\s*h/i); if (hourMatch) { totalMinutes += parseInt(hourMatch[1], 10) * 60; } // Match minutes: "5m" or "5 m" const minuteMatch = runtimeStr.match(/(\d+)\s*m/i); if (minuteMatch) { totalMinutes += parseInt(minuteMatch[1], 10); } return totalMinutes; } /** * Extracts total runtime from episode DOM elements on season page * @param {HTMLElement} activePage - The active page element * @returns {Promise} Total runtime in minutes, or 0 if not found */ async function extractSeasonRuntimeFromDOM(activePage) { // Check for #listChildrenCollapsible first (10.11+) let childrenCollapsible = activePage.querySelector('#listChildrenCollapsible'); // Fallback to #childrenCollapsible (10.10) if (!childrenCollapsible) { childrenCollapsible = activePage.querySelector('#childrenCollapsible'); } if (!childrenCollapsible) { WARN('Could not find childrenCollapsible element for season runtime extraction'); return 0; } const itemsContainer = childrenCollapsible.querySelector('.itemsContainer'); if (!itemsContainer) { WARN('Could not find itemsContainer in childrenCollapsible'); return 0; } // Wait for episode containers to appear (up to 10 seconds) const maxAttempts = 20; // 20 attempts * 500ms = 10 seconds const interval = 500; let attempts = 0; while (attempts < maxAttempts) { // Get all direct children (episode containers) const episodeContainers = itemsContainer.querySelectorAll(':scope > *'); // Check if we have episode containers with runtime data let hasValidContainers = false; if (episodeContainers.length > 0) { // Check if at least one container has the expected structure for (const container of episodeContainers) { const mediaInfo = container.querySelector('.secondary.listItemMediaInfo.listItemBodyText'); if (mediaInfo) { const mediaInfoItem = mediaInfo.querySelector(':scope > .mediaInfoItem'); if (mediaInfoItem) { hasValidContainers = true; break; } } } } if (hasValidContainers) { // Found valid containers, extract runtimes let totalMinutes = 0; let parsedCount = 0; episodeContainers.forEach(episodeContainer => { // Find .secondary.listItemMediaInfo.listItemBodyText const mediaInfo = episodeContainer.querySelector('.secondary.listItemMediaInfo.listItemBodyText'); if (!mediaInfo) { return; } // Find .mediaInfoItem direct child const mediaInfoItem = mediaInfo.querySelector(':scope > .mediaInfoItem'); if (!mediaInfoItem) { return; } const runtimeText = (mediaInfoItem.innerText || mediaInfoItem.textContent || '').trim(); if (runtimeText) { const minutes = parseRuntimeString(runtimeText); if (minutes > 0) { totalMinutes += minutes; parsedCount++; } } }); LOG(`Extracted runtime from ${parsedCount} episodes: ${totalMinutes} minutes total`); return totalMinutes; } attempts++; if (attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, interval)); } } WARN('Could not find episode containers with runtime data after waiting 10 seconds'); return 0; } /** * Formats RunTimeTicks into an end time string * @param {number} runTimeTicks - Runtime in ticks (100-nanosecond intervals) * @returns {string} Formatted end time (e.g., "2:40 AM Dec 6" or "2:40 AM" if same day) */ function formatEndTime(runTimeTicks) { if (!runTimeTicks || runTimeTicks <= 0) { return null; } const now = new Date(); // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds const runtimeMs = runTimeTicks / 10000; const endTime = new Date(now.getTime() + runtimeMs); // Format time const timeStr = endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: !state.use24HourTimeFormat }); // Check if end date is the same as current day const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const endDate = new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate()); if (nowDate.getTime() === endDate.getTime()) { // Same day, return only time return timeStr; } else { // Different day, include date const dateStr = endTime.toLocaleDateString([], { month: 'short', day: 'numeric' }); return `${timeStr} ${dateStr}`; } } /** * Formats total minutes into an end time string * @param {number} totalMinutes - Total runtime in minutes * @returns {string} Formatted end time (e.g., "2:40 AM Dec 6" or "2:40 AM" if same day) */ function formatEndTimeFromMinutes(totalMinutes) { if (!totalMinutes || totalMinutes <= 0) { return null; } const now = new Date(); // Convert minutes to milliseconds const runtimeMs = totalMinutes * 60 * 1000; const endTime = new Date(now.getTime() + runtimeMs); // Format time const timeStr = endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: !state.use24HourTimeFormat }); // Check if end date is the same as current day const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const endDate = new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate()); if (nowDate.getTime() === endDate.getTime()) { // Same day, return only time return timeStr; } else { // Different day, include date const dateStr = endTime.toLocaleDateString([], { month: 'short', day: 'numeric' }); return `${timeStr} ${dateStr}`; } } const seriesSeasonsCountCache = new Map(); const seriesSeasonEpisodeCountCache = new Map(); async function getSeasonsCount(item, includeSpecials = false) { if (!seriesSeasonsCountCache.has(item.Id)) { // Get Seasons from apiHelper.getQuery const data = await apiHelper.getQuery(`${ApiClient.serverAddress()}/Shows/${item.Id}/Seasons?UserId=${ApiClient.getCurrentUserId()}`); let seasons = data.Items || []; if (!includeSpecials) { seasons = seasons.filter(season => season.IndexNumber !== 0); } seriesSeasonsCountCache.set(item.Id, seasons.length); } return seriesSeasonsCountCache.get(item.Id); } async function getEpisodesCount(seriesId, seasonIndex = null, includeSpecials = false) { if (!seriesSeasonEpisodeCountCache.has(seriesId)) { const data = await apiHelper.getQuery(`${ApiClient.serverAddress()}/Shows/${seriesId}/Episodes?UserId=${ApiClient.getCurrentUserId()}`); let episodes = data.Items || []; // Create a Season:EpisodesCount map const seasonEpisodeCountMap = new Map(); episodes.forEach(episode => { const seasonIndex = episode.ParentIndexNumber; if (!seasonEpisodeCountMap.has(seasonIndex)) { seasonEpisodeCountMap.set(seasonIndex, 0); } seasonEpisodeCountMap.set(seasonIndex, seasonEpisodeCountMap.get(seasonIndex) + 1); }); seriesSeasonEpisodeCountCache.set(seriesId, seasonEpisodeCountMap); } // Return the episodes count for the season index if (seasonIndex !== null && !isNaN(seasonIndex)) { return seriesSeasonEpisodeCountCache.get(seriesId).get(seasonIndex) || 0; } // Return the total episodes count for all seasons if (!includeSpecials) { // Ignore season 0 const specialsCount = seriesSeasonEpisodeCountCache.get(seriesId).get(0) || 0; return seriesSeasonEpisodeCountCache.get(seriesId).values().reduce((a, b) => a + b, 0) - specialsCount; } return seriesSeasonEpisodeCountCache.get(seriesId).values().reduce((a, b) => a + b, 0); } /** * Adds series info to the itemMiscInfo container * @param {Object} item - Series item object * @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container */ async function addSeriesInfo(item, miscInfoContainer) { // Check if already added if (miscInfoContainer.querySelector('.kt-series-info')) { LOG('Series info already added, skipping'); return; } const infoContainer = document.createElement('div'); infoContainer.className = 'kt-series-info'; infoContainer.style.display = 'flex'; infoContainer.style.margin = '0 1em 0 0'; const seasonsCount = await getSeasonsCount(item); const episodesCount = await getEpisodesCount(item.Id); let childCount = item.ChildCount || 0; // Seasons count (only show if more than 1 season) if (childCount > 1) { const seasonsDiv = document.createElement('div'); seasonsDiv.className = 'mediaInfoItem'; seasonsDiv.textContent = `${seasonsCount} Seasons`; infoContainer.appendChild(seasonsDiv); } // Episodes count const RecursiveItemCount = item.RecursiveItemCount || 0; if (RecursiveItemCount > 0) { const episodesDiv = document.createElement('div'); episodesDiv.className = 'mediaInfoItem'; episodesDiv.textContent = `${episodesCount} ${episodesCount === 1 ? 'Episode' : 'Episodes'}`; infoContainer.appendChild(episodesDiv); } // End time - RunTimeTicks is for first episode, multiply by episode count for total if (item.RunTimeTicks && item.RunTimeTicks > 0 && RecursiveItemCount > 0) { const totalRuntimeTicks = item.RunTimeTicks * RecursiveItemCount; const endTimeStr = formatEndTime(totalRuntimeTicks); if (endTimeStr) { const endTimeDiv = document.createElement('div'); endTimeDiv.className = 'mediaInfoItem'; endTimeDiv.textContent = `Ends at ${endTimeStr}`; infoContainer.appendChild(endTimeDiv); } } // Append to miscInfoContainer if (infoContainer.children.length > 0) { miscInfoContainer.appendChild(infoContainer); LOG('Added series info to itemMiscInfo container'); } } /** * Adds season info to the itemMiscInfo container * @param {Object} item - Season item object * @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container * @param {HTMLElement} activePage - The active page element */ async function addSeasonInfo(item, miscInfoContainer, activePage) { // Remove hide class if present if (miscInfoContainer.classList.contains('hide')) { miscInfoContainer.classList.remove('hide'); LOG('Removed hide class from itemMiscInfo container'); } // Check if already added if (miscInfoContainer.querySelector('.kt-season-info')) { LOG('Season info already added, skipping'); return; } const infoContainer = document.createElement('div'); infoContainer.className = 'kt-season-info'; infoContainer.style.display = 'flex'; // Episodes count const episodeCount = await getEpisodesCount(item.SeriesId, item.IndexNumber); if (episodeCount > 0) { const episodesDiv = document.createElement('div'); episodesDiv.className = 'mediaInfoItem'; episodesDiv.textContent = `${episodeCount} ${episodeCount === 1 ? 'Episode' : 'Episodes'}`; infoContainer.appendChild(episodesDiv); } // End time - extract from DOM episode runtimes const totalMinutes = await extractSeasonRuntimeFromDOM(activePage); if (totalMinutes > 0) { const endTimeStr = formatEndTimeFromMinutes(totalMinutes); if (endTimeStr) { const endTimeDiv = document.createElement('div'); endTimeDiv.className = 'mediaInfoItem'; endTimeDiv.textContent = `Ends at ${endTimeStr}`; infoContainer.appendChild(endTimeDiv); } } // Append to miscInfoContainer if (infoContainer.children.length > 0) { miscInfoContainer.appendChild(infoContainer); LOG('Added season info to itemMiscInfo container'); } } /** * Processes a details page for Series or Season items * @param {Object} item - The item object */ async function processDetailsPage(item) { if (!item || (item.Type !== 'Series' && item.Type !== 'Season')) { return; } const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) { WARN('Active page not found'); return; } // Find the itemMiscInfo container const miscInfoContainer = activePage.querySelector('.itemMiscInfo'); if (!miscInfoContainer) { WARN('itemMiscInfo container not found'); return; } LOG(`Processing ${item.Type} details page for: ${item.Name}`); if (item.Type === 'Series') { addSeriesInfo(item, miscInfoContainer); } else if (item.Type === 'Season') { addSeasonInfo(item, miscInfoContainer, activePage); } } /** * Initialize the series info hook */ function initializeSeriesInfoHook() { if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); setTimeout(initializeSeriesInfoHook, 1000); return; } // Get the use24HourTime setting from the configuration const config = window.KefinTweaksConfig || {}; let seriesInfoConfig = config.seriesInfo || null; if (seriesInfoConfig === null) { // Use the browser's default time format seriesInfoConfig = { use24HourTimeFormat: Intl.DateTimeFormat().resolvedOptions().timeStyle === 'short' }; } state.use24HourTimeFormat = seriesInfoConfig.use24HourTimeFormat === true; LOG('Registering series info 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; if (!item || (item.Type !== 'Series' && item.Type !== 'Season')) { return; } // Small delay to ensure details DOM is ready setTimeout(() => { processDetailsPage(item); }, 100); }, { pages: ['details'] } ); LOG('Series info hook initialized'); } // Initialize the hook when the script loads initializeSeriesInfoHook(); console.log('[KefinTweaks SeriesInfo] Module loaded and ready'); })();