mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add SeriesInfo+ and Episodes to all Series page (with Flatten Single Season option)
This commit is contained in:
+89
-15
@@ -5,12 +5,54 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
console.log('[KefinTweaks Injector] Initializing...');
|
||||
const versionNumber = "0.3.3";
|
||||
|
||||
console.log('[KefinTweaks Injector] Initializing...');
|
||||
// Cache for resolved root URL (to avoid multiple API calls)
|
||||
let resolvedRootCache = null;
|
||||
|
||||
/**
|
||||
* Extracts version string from root URL
|
||||
* @param {string} root - The root URL (e.g., "https://cdn.jsdelivr.net/gh/ranaldsgift/[email protected]/")
|
||||
* @returns {string} Version string (e.g., "0.3.3", "latest", "development", or commit hash)
|
||||
*/
|
||||
function extractVersionFromRoot(root) {
|
||||
if (!root) {
|
||||
return "Pending Install"; // Fallback
|
||||
}
|
||||
|
||||
// Match version patterns: @v0.3.3, @latest, @main, or @commitHash
|
||||
const versionMatch = root.match(/@(v?[\d.]+|latest|main|[a-f0-9]{7,})/i);
|
||||
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1];
|
||||
|
||||
// Handle version tags (v0.3.3 -> 0.3.3)
|
||||
if (version.startsWith('v') && /^\d+\.\d+\.\d+/.test(version.substring(1))) {
|
||||
return version.substring(1); // Remove 'v' prefix
|
||||
}
|
||||
|
||||
// Handle @main -> "development"
|
||||
if (version.toLowerCase() === 'main') {
|
||||
return 'development';
|
||||
}
|
||||
|
||||
// Handle @latest -> "latest"
|
||||
if (version.toLowerCase() === 'latest') {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
// Handle commit hash -> show as "development" or short hash
|
||||
if (/^[a-f0-9]{7,}$/i.test(version)) {
|
||||
return `development (${version.substring(0, 7)})`;
|
||||
}
|
||||
|
||||
// Return as-is for other patterns
|
||||
return "version";
|
||||
}
|
||||
|
||||
// No version found in URL, this is a custom install
|
||||
return "Custom Install";
|
||||
}
|
||||
|
||||
// Configuration: Start with defaults, then merge user config
|
||||
// This allows new scripts to work out of the box without requiring config updates
|
||||
const ENABLED_SCRIPTS = {
|
||||
@@ -30,6 +72,7 @@
|
||||
playlist: true, // Playlist view page modifications
|
||||
itemDetailsCollections: true, // Add related collections to item details pages
|
||||
flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly
|
||||
seriesInfo: true, // Add series and season information to details pages
|
||||
collections: true, // Collection sorting functionality
|
||||
skinManager: true, // Skin selection and management
|
||||
|
||||
@@ -220,10 +263,17 @@
|
||||
},
|
||||
{
|
||||
name: 'flattenSingleSeasonShows',
|
||||
script: 'flattenSingleSeasonShows.js',
|
||||
script: 'seriesEpisodes.js',
|
||||
css: null,
|
||||
dependencies: ['cardBuilder', 'utils'],
|
||||
description: 'Flattens series with only 1 season to show episodes directly on the series details page'
|
||||
description: 'Displays episodes directly on series page with season selection. Works for both single and multi-season shows when enabled.'
|
||||
},
|
||||
{
|
||||
name: 'seriesInfo',
|
||||
script: 'seriesInfo.js',
|
||||
css: null,
|
||||
dependencies: ['utils'],
|
||||
description: 'Adds series and season information (seasons count, episodes count, end time) to details pages'
|
||||
},
|
||||
{
|
||||
name: 'collections',
|
||||
@@ -239,21 +289,43 @@
|
||||
//const urlSuffix = `?v=${new Date().getTime()}`;
|
||||
|
||||
// Inject dashboard drawer version badge CSS using dynamic version number
|
||||
function injectVersionBadgeCSS() {
|
||||
async function injectVersionBadgeCSS() {
|
||||
const styleId = 'kefinTweaks-version-css';
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
// Get version from root URL in config
|
||||
let displayVersion = "";
|
||||
try {
|
||||
const configRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
|
||||
if (configRoot) {
|
||||
// Resolve the root to get the actual version (if @latest or @main)
|
||||
const resolvedRoot = await resolveRootVersion(configRoot);
|
||||
displayVersion = extractVersionFromRoot(resolvedRoot);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[KefinTweaks Injector] Could not extract version, using fallback:', error);
|
||||
}
|
||||
|
||||
// Format version for display (add 'v' prefix if it's a version number, not for 'latest' or 'development')
|
||||
let versionDisplay = displayVersion;
|
||||
if (!displayVersion.startsWith('v') &&
|
||||
!displayVersion.includes('latest') &&
|
||||
!displayVersion.includes('development') &&
|
||||
/^\d+\.\d+/.test(displayVersion)) {
|
||||
versionDisplay = 'v' + displayVersion;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after {
|
||||
content: 'KefinTweaks v${versionNumber}';
|
||||
content: 'KefinTweaks ${versionDisplay}';
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
color: #b7b7b7;
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
cursor: help;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -550,7 +622,7 @@
|
||||
|
||||
// Main initialization function
|
||||
async function initialize() {
|
||||
console.log(`[KefinTweaks Injector] Starting KefinTweaks v${versionNumber} initialization...`);
|
||||
console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`);
|
||||
|
||||
const scriptRoot = await getScriptRoot();
|
||||
if (!scriptRoot) {
|
||||
@@ -934,17 +1006,19 @@
|
||||
|
||||
// Start initialization when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
injectVersionBadgeCSS();
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await injectVersionBadgeCSS();
|
||||
initialize();
|
||||
// Run startup task after a short delay to ensure ApiClient is ready
|
||||
setTimeout(startupTask, 1000);
|
||||
});
|
||||
} else {
|
||||
injectVersionBadgeCSS();
|
||||
initialize();
|
||||
// Run startup task after a short delay to ensure ApiClient is ready
|
||||
setTimeout(startupTask, 1000);
|
||||
(async () => {
|
||||
await injectVersionBadgeCSS();
|
||||
initialize();
|
||||
// Run startup task after a short delay to ensure ApiClient is ready
|
||||
setTimeout(startupTask, 1000);
|
||||
})();
|
||||
}
|
||||
|
||||
console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks');
|
||||
|
||||
@@ -391,6 +391,9 @@ window.KefinTweaksDefaultConfig = {
|
||||
"search": {
|
||||
"enableJellyseerr": false
|
||||
},
|
||||
"flattenSingleSeasonShows": {
|
||||
"hideSingleSeasonContainer": false
|
||||
},
|
||||
"skins": [],
|
||||
"defaultSkin": null,
|
||||
"themes": [],
|
||||
|
||||
@@ -393,6 +393,9 @@ window.KefinTweaksConfig = {
|
||||
"search": {
|
||||
"enableJellyseerr": false
|
||||
},
|
||||
"flattenSingleSeasonShows": {
|
||||
"showEpisodesOnSeriesPage": false
|
||||
},
|
||||
"skins": [],
|
||||
"defaultSkin": null,
|
||||
"themes": [],
|
||||
|
||||
@@ -437,6 +437,7 @@
|
||||
const homeScreen = config.homeScreen || {};
|
||||
const exclusiveElsewhere = config.exclusiveElsewhere || {};
|
||||
const search = config.search || {};
|
||||
const flattenSingleSeasonShows = config.flattenSingleSeasonShows || {};
|
||||
const customMenuLinks = config.customMenuLinks || [];
|
||||
// Use merged skin config from skinManager (includes both defaults and admin skins)
|
||||
const skins = window.KefinTweaksSkinConfig || [];
|
||||
@@ -509,6 +510,16 @@
|
||||
${buildSearchConfig(search)}
|
||||
</div>
|
||||
|
||||
<div class="paperList" id="configSection_flattenSingleSeasonShows" style="margin-bottom: 2em; ${scripts.flattenSingleSeasonShows === false ? 'display: none;' : ''}">
|
||||
<div class="listItem" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1em; margin-bottom: 1em;">
|
||||
<div class="listItemContent">
|
||||
<h3 class="listItemBodyText" style="margin-bottom: 0.25em;">Episodes On Series Page Configuration</h3>
|
||||
<div class="listItemBodyText secondary">Configure episodes display on series pages</div>
|
||||
</div>
|
||||
</div>
|
||||
${buildFlattenShowsConfig(flattenSingleSeasonShows)}
|
||||
</div>
|
||||
|
||||
<div class="paperList" id="configSection_skin" style="margin-bottom: 2em; ${scripts.skinManager === false ? 'display: none;' : ''}">
|
||||
<div class="listItem" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1em; margin-bottom: 1em;">
|
||||
<div class="listItemContent">
|
||||
@@ -953,7 +964,8 @@
|
||||
{ key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' },
|
||||
{ key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' },
|
||||
{ key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' },
|
||||
{ key: 'flattenSingleSeasonShows', label: 'Flatten Single Season Shows', desc: 'Flattens single-season shows to display episodes directly on series page' },
|
||||
{ key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' },
|
||||
{ key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' },
|
||||
{ key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' },
|
||||
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
|
||||
{ key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' },
|
||||
@@ -1974,6 +1986,20 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// Build flatten shows configuration
|
||||
function buildFlattenShowsConfig(flattenShows) {
|
||||
const hideSingleSeasonContainer = flattenShows?.hideSingleSeasonContainer === true;
|
||||
return `
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
|
||||
<div class="listItemContent">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Flatten Single Season Shows</div>
|
||||
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.</div>
|
||||
${buildJellyfinCheckbox('flattenSingleSeasonShows_hideSingleSeasonContainer', hideSingleSeasonContainer, 'Enabled')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Open configuration modal
|
||||
async function openConfigurationModal() {
|
||||
console.log('[KefinTweaks Configuration] Opening configuration modal...');
|
||||
@@ -4159,7 +4185,8 @@
|
||||
search: modalInstance.dialogContent.querySelector('#script_search'),
|
||||
exclusiveElsewhere: modalInstance.dialogContent.querySelector('#script_exclusiveElsewhere'),
|
||||
skinManager: modalInstance.dialogContent.querySelector('#script_skinManager'),
|
||||
customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks')
|
||||
customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks'),
|
||||
flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#script_flattenSingleSeasonShows')
|
||||
};
|
||||
|
||||
const configSections = {
|
||||
@@ -4167,7 +4194,8 @@
|
||||
exclusiveElsewhere: modalInstance.dialogContent.querySelector('#configSection_exclusiveElsewhere'),
|
||||
skin: modalInstance.dialogContent.querySelector('#configSection_skin'),
|
||||
theme: modalInstance.dialogContent.querySelector('#configSection_theme'),
|
||||
customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks')
|
||||
customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks'),
|
||||
flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#configSection_flattenSingleSeasonShows')
|
||||
};
|
||||
|
||||
// Function to toggle section visibility
|
||||
@@ -4220,6 +4248,10 @@
|
||||
scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks'));
|
||||
}
|
||||
|
||||
if (scriptCheckboxes.flattenSingleSeasonShows) {
|
||||
scriptCheckboxes.flattenSingleSeasonShows.addEventListener('change', () => toggleSectionVisibility('flattenSingleSeasonShows', 'flattenSingleSeasonShows'));
|
||||
}
|
||||
|
||||
// Save button handler
|
||||
const saveBtn = modalInstance.dialogFooter?.querySelector('#saveConfigBtn');
|
||||
if (saveBtn) {
|
||||
@@ -5092,6 +5124,11 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true
|
||||
};
|
||||
|
||||
// Collect flattenSingleSeasonShows config
|
||||
config.flattenSingleSeasonShows = {
|
||||
hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true
|
||||
};
|
||||
|
||||
// Parse JSON fields
|
||||
const skinsFromJson = parseJSONField('skinsJson', []);
|
||||
config.themes = parseJSONField('themesJson', []);
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
// KefinTweaks Series Episodes
|
||||
// Displays episodes directly on series page with season selection
|
||||
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks SeriesEpisodes]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes]', ...args);
|
||||
|
||||
/**
|
||||
* Fetches the Next Up episode for a given series
|
||||
* @param {string} seriesId - The series ID
|
||||
* @returns {Promise<Object|null>} - Episode number object with season and episode, or null
|
||||
*/
|
||||
async function fetchNextUpEpisode(seriesId) {
|
||||
try {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
LOG(`Fetching Next Up episode for series: ${seriesId}`);
|
||||
const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`;
|
||||
const nextUpRes = await fetch(nextUpUrl, {
|
||||
headers: { "Authorization": `MediaBrowser Token="${token}"` }
|
||||
});
|
||||
|
||||
if (!nextUpRes.ok) {
|
||||
throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`);
|
||||
}
|
||||
|
||||
const nextUpData = await nextUpRes.json();
|
||||
const items = nextUpData.Items || [];
|
||||
|
||||
if (items.length > 0) {
|
||||
const item = items[0];
|
||||
if (item.IndexNumber && item.ParentIndexNumber) {
|
||||
const episodeNumber = {
|
||||
season: item.ParentIndexNumber,
|
||||
episode: item.IndexNumber
|
||||
};
|
||||
LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`);
|
||||
return episodeNumber;
|
||||
}
|
||||
}
|
||||
|
||||
LOG('No Next Up episode found in API response');
|
||||
return null;
|
||||
} catch (error) {
|
||||
ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches season data by season ID
|
||||
* @param {string} seasonId - The season ID
|
||||
* @returns {Promise<Object|null>} - Season item or null
|
||||
*/
|
||||
async function fetchSeasonData(seasonId) {
|
||||
try {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
LOG(`Fetching season data for season ID: ${seasonId}`);
|
||||
const seasonUrl = `${serverUrl}/Items/${seasonId}?UserId=${userId}`;
|
||||
const seasonRes = await fetch(seasonUrl, {
|
||||
headers: { "Authorization": `MediaBrowser Token="${token}"` }
|
||||
});
|
||||
|
||||
if (!seasonRes.ok) {
|
||||
throw new Error(`HTTP ${seasonRes.status}: ${seasonRes.statusText}`);
|
||||
}
|
||||
|
||||
const seasonData = await seasonRes.json();
|
||||
LOG(`Fetched season data: IndexNumber=${seasonData.IndexNumber}`);
|
||||
return seasonData;
|
||||
} catch (error) {
|
||||
ERR(`Failed to fetch season data for ${seasonId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all episodes for a given series
|
||||
* @param {string} seriesId - The series ID
|
||||
* @param {string} [seasonId] - Optional season ID to filter episodes
|
||||
* @returns {Promise<Array>} - Array of episode items
|
||||
*/
|
||||
async function fetchEpisodesForSeries(seriesId, seasonId = null) {
|
||||
try {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
LOG(`Fetching episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`);
|
||||
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`;
|
||||
const episodesRes = await fetch(episodesUrl, {
|
||||
headers: { "Authorization": `MediaBrowser Token="${token}"` }
|
||||
});
|
||||
|
||||
if (!episodesRes.ok) {
|
||||
throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`);
|
||||
}
|
||||
|
||||
const episodesData = await episodesRes.json();
|
||||
let episodes = episodesData.Items || [];
|
||||
|
||||
// Filter by season ID if specified
|
||||
if (seasonId !== null) {
|
||||
episodes = episodes.filter(ep => ep.SeasonId === seasonId);
|
||||
}
|
||||
|
||||
LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`);
|
||||
return episodes;
|
||||
} catch (error) {
|
||||
ERR(`Failed to fetch episodes for series ${seriesId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts season data from the rendered DOM
|
||||
* @param {HTMLElement} activePage - The active page element
|
||||
* @returns {Array} - Array of season objects with { id, name, indexNumber }
|
||||
*/
|
||||
function extractSeasonsFromDOM(activePage) {
|
||||
const seasons = [];
|
||||
|
||||
// Check for 10.11+ seasons section first (.detailSection #listChildrenCollapsible)
|
||||
let childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible');
|
||||
|
||||
// Fallback to 10.10 seasons section (#childrenCollapsible)
|
||||
if (!childrenCollapsible) {
|
||||
childrenCollapsible = activePage.querySelector('#childrenCollapsible');
|
||||
}
|
||||
|
||||
if (!childrenCollapsible) {
|
||||
WARN('Could not find childrenCollapsible element');
|
||||
return seasons;
|
||||
}
|
||||
|
||||
const itemsContainer = childrenCollapsible.querySelector('.itemsContainer');
|
||||
if (!itemsContainer) {
|
||||
WARN('Could not find itemsContainer in childrenCollapsible');
|
||||
return seasons;
|
||||
}
|
||||
|
||||
// Get all .card immediate children
|
||||
const seasonCards = itemsContainer.querySelectorAll(':scope > .card');
|
||||
|
||||
seasonCards.forEach(card => {
|
||||
const seasonId = card.getAttribute('data-id');
|
||||
if (!seasonId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get season name from .cardText a link
|
||||
const cardTextLink = card.querySelector('.cardText a');
|
||||
const seasonName = cardTextLink ? (cardTextLink.innerText || cardTextLink.textContent).trim() : null;
|
||||
|
||||
// Try to extract index number from season name or data attributes
|
||||
let indexNumber = null;
|
||||
|
||||
// Try to parse from season name (e.g., "Season 1", "S1", etc.)
|
||||
if (seasonName) {
|
||||
const seasonMatch = seasonName.match(/Season\s+(\d+)/i) || seasonName.match(/S(\d+)/i);
|
||||
if (seasonMatch) {
|
||||
indexNumber = parseInt(seasonMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't parse from name, try data-index or other attributes
|
||||
if (indexNumber === null) {
|
||||
const dataIndex = card.getAttribute('data-index');
|
||||
if (dataIndex) {
|
||||
indexNumber = parseInt(dataIndex, 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (seasonId) {
|
||||
seasons.push({
|
||||
id: seasonId,
|
||||
name: seasonName || `Season ${indexNumber || 'Unknown'}`,
|
||||
indexNumber: indexNumber
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by index number
|
||||
seasons.sort((a, b) => {
|
||||
const aIndex = a.indexNumber !== null ? a.indexNumber : 999;
|
||||
const bIndex = b.indexNumber !== null ? b.indexNumber : 999;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
LOG(`Extracted ${seasons.length} seasons from DOM:`, seasons);
|
||||
return seasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a season name, adding season number in parentheses if not in "Season XX" format
|
||||
* @param {string} seasonName - The season name
|
||||
* @param {number} seasonNumber - The season number
|
||||
* @returns {string} - Formatted season name
|
||||
*/
|
||||
function formatSeasonName(seasonName, seasonNumber) {
|
||||
if (!seasonName) {
|
||||
return `Season ${seasonNumber}`;
|
||||
}
|
||||
|
||||
return seasonName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a season selector button with popover for multi-season shows
|
||||
* @param {HTMLElement} sectionTitleContainer - The section title container element
|
||||
* @param {Array} seasons - Array of season objects
|
||||
* @param {Object} currentSeason - The currently selected season
|
||||
* @param {Function} onSeasonSelect - Callback when a season is selected
|
||||
*/
|
||||
function createSeasonSelector(sectionTitleContainer, seasons, currentSeason, onSeasonSelect) {
|
||||
// Create select season button
|
||||
const selectButton = document.createElement('button');
|
||||
selectButton.className = 'emby-button raised season-selector-button';
|
||||
selectButton.textContent = 'Select Season';
|
||||
selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;';
|
||||
selectButton.setAttribute('aria-label', 'Select season');
|
||||
|
||||
// Find the <a> tag link (titleLink) and append button after it
|
||||
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
|
||||
if (titleLink) {
|
||||
titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling);
|
||||
} else {
|
||||
const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle');
|
||||
if (sectionTitle) {
|
||||
sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling);
|
||||
} else {
|
||||
sectionTitleContainer.appendChild(selectButton);
|
||||
}
|
||||
}
|
||||
|
||||
let activePopover = null;
|
||||
|
||||
const closePopover = () => {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createPopover = () => {
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'kefinTweaks-popover seasonPopover itemDetailsGroup';
|
||||
|
||||
const seasonPopoverCss = `
|
||||
.seasonPopover {
|
||||
background-color: rgba(0, 0, 0, 0.95);
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = seasonPopoverCss;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
// Calculate left position based on right edge of the <a> tag link
|
||||
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
|
||||
if (titleLink) {
|
||||
const titleLinkRect = titleLink.getBoundingClientRect();
|
||||
const sectionTitleContainerRect = sectionTitleContainer.getBoundingClientRect();
|
||||
const leftOffset = titleLinkRect.right - sectionTitleContainerRect.left;
|
||||
popover.style.left = `${leftOffset}px`;
|
||||
} else {
|
||||
popover.style.left = '0';
|
||||
}
|
||||
|
||||
let selectedItemElement = null;
|
||||
seasons.forEach(season => {
|
||||
const itemElement = document.createElement('div');
|
||||
itemElement.className = 'kefinTweaks-popover-item';
|
||||
|
||||
if (currentSeason && season.id === currentSeason.id) {
|
||||
itemElement.classList.add('selected');
|
||||
selectedItemElement = itemElement;
|
||||
}
|
||||
|
||||
const displayText = formatSeasonName(`${season.name}`, season.indexNumber);
|
||||
itemElement.textContent = displayText;
|
||||
itemElement.addEventListener('click', () => {
|
||||
LOG(`Season selected: ${displayText}`);
|
||||
onSeasonSelect(season);
|
||||
closePopover();
|
||||
});
|
||||
|
||||
popover.appendChild(itemElement);
|
||||
});
|
||||
|
||||
// Store reference to selected item for scrolling
|
||||
popover._selectedItem = selectedItemElement;
|
||||
|
||||
return popover;
|
||||
};
|
||||
|
||||
selectButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (activePopover) {
|
||||
closePopover();
|
||||
} else {
|
||||
const popover = createPopover();
|
||||
sectionTitleContainer.style.position = 'relative';
|
||||
selectButton.parentNode.insertBefore(popover, selectButton.nextSibling);
|
||||
|
||||
// Check if popover would overflow bottom of viewport
|
||||
const popoverRect = popover.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const wouldOverflowBottom = popoverRect.bottom > viewportHeight;
|
||||
|
||||
if (wouldOverflowBottom) {
|
||||
popover.style.top = 'auto';
|
||||
popover.style.bottom = '100%';
|
||||
popover.style.marginTop = '0';
|
||||
popover.style.marginBottom = '8px';
|
||||
LOG('Popover positioned above button to avoid overflow');
|
||||
} else {
|
||||
popover.style.top = '100%';
|
||||
popover.style.bottom = 'auto';
|
||||
popover.style.marginTop = '8px';
|
||||
popover.style.marginBottom = '0';
|
||||
}
|
||||
|
||||
activePopover = popover;
|
||||
|
||||
// Scroll to selected season if it exists
|
||||
if (popover._selectedItem) {
|
||||
setTimeout(() => {
|
||||
popover._selectedItem.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'nearest',
|
||||
inline: 'nearest'
|
||||
});
|
||||
LOG('Auto-scrolled to selected season:', popover._selectedItem.textContent);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
const closeHandler = (e) => {
|
||||
if (!popover.contains(e.target) && !selectButton.contains(e.target)) {
|
||||
closePopover();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeHandler);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to a specific episode in the scroller container
|
||||
* @param {HTMLElement} scrollerContainer - The scroller container element
|
||||
* @param {Object} targetEpisodeNumber - Object with season and episode numbers
|
||||
*/
|
||||
function scrollToEpisode(scrollerContainer, targetEpisodeNumber) {
|
||||
if (!scrollerContainer || !targetEpisodeNumber) return;
|
||||
|
||||
setTimeout(() => {
|
||||
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
|
||||
if (!scrollerItemsContainer) {
|
||||
WARN('Could not find itemsContainer in scroller');
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = scrollerItemsContainer.querySelectorAll('.card');
|
||||
let targetCard = null;
|
||||
|
||||
for (const card of cards) {
|
||||
const cardTextLinks = card.querySelectorAll('.cardText a');
|
||||
for (const link of cardTextLinks) {
|
||||
const linkText = link.innerText || link.textContent;
|
||||
const match = linkText.match(/S(\d+):E(\d+)/);
|
||||
if (match &&
|
||||
parseInt(match[1], 10) === targetEpisodeNumber.season &&
|
||||
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
|
||||
targetCard = card;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetCard) break;
|
||||
}
|
||||
|
||||
if (targetCard) {
|
||||
const itemsContainer = scrollerItemsContainer;
|
||||
const cardOffset = targetCard.offsetLeft;
|
||||
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
|
||||
const translateX = (cardOffset - scrollerPadding);
|
||||
scrollerContainer.scrollToPosition(translateX);
|
||||
|
||||
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
|
||||
if (scrollButtons) {
|
||||
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
|
||||
if (leftButton) {
|
||||
leftButton.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
||||
} else {
|
||||
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the seasons section to be rendered in the DOM
|
||||
* @param {HTMLElement} activePage - The active page element
|
||||
* @param {number} maxAttempts - Maximum number of attempts
|
||||
* @param {number} interval - Interval between attempts in ms
|
||||
* @returns {Promise<HTMLElement|null>} - The childrenCollapsible element or null
|
||||
*/
|
||||
function waitForSeasonsSection(activePage, maxAttempts = 10, interval = 500) {
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
|
||||
const checkForSeasons = () => {
|
||||
attempts++;
|
||||
|
||||
// Check for 10.11+ seasons section first
|
||||
let childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible');
|
||||
|
||||
// Fallback to 10.10 seasons section
|
||||
if (!childrenCollapsible) {
|
||||
childrenCollapsible = activePage.querySelector('#childrenCollapsible');
|
||||
}
|
||||
|
||||
if (childrenCollapsible) {
|
||||
const itemsContainer = childrenCollapsible.querySelector('.itemsContainer');
|
||||
if (itemsContainer && itemsContainer.children.length > 0) {
|
||||
LOG('Seasons section found and rendered');
|
||||
resolve(childrenCollapsible);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
WARN('Seasons section not found after maximum attempts');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(checkForSeasons, interval);
|
||||
};
|
||||
|
||||
checkForSeasons();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders episodes section for a series
|
||||
* @param {string} seriesId - The series ID
|
||||
* @param {Array} seasons - Array of season objects from DOM
|
||||
* @param {Object} targetSeason - The target season to display
|
||||
* @param {Object} targetEpisodeNumber - Target episode number for scrolling
|
||||
* @param {string} nextUpHeaderText - Text from NextUp section header
|
||||
*/
|
||||
async function renderEpisodesSection(seriesId, seasons, targetSeason, targetEpisodeNumber, nextUpHeaderText) {
|
||||
const activePage = document.querySelector('.libraryPage:not(.hide)');
|
||||
if (!activePage) {
|
||||
WARN('Active page not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already rendered
|
||||
if (activePage.querySelector('.series-episodes-section')) {
|
||||
LOG('Episodes section already rendered, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch episodes for the target season
|
||||
const episodes = await fetchEpisodesForSeries(seriesId, targetSeason.id);
|
||||
if (episodes.length === 0) {
|
||||
WARN(`No episodes found for season ${targetSeason.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for cardBuilder
|
||||
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
|
||||
WARN('cardBuilder.renderCards not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format season name
|
||||
const seasonName = formatSeasonName(targetSeason.name, targetSeason.indexNumber);
|
||||
|
||||
// Build viewMoreUrl
|
||||
const apiClient = window.ApiClient;
|
||||
const serverId = apiClient.serverId();
|
||||
const viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${targetSeason.id}&serverId=${serverId}`;
|
||||
|
||||
// Render season section using renderCards
|
||||
const seasonSection = window.cardBuilder.renderCards(
|
||||
episodes,
|
||||
seasonName,
|
||||
viewMoreUrl,
|
||||
true, // overflowCard
|
||||
null // cardFormat (use default)
|
||||
);
|
||||
|
||||
// Mark as series episodes section
|
||||
seasonSection.classList.add('series-episodes-section');
|
||||
seasonSection.classList.add('nextUpSection');
|
||||
|
||||
// ElegantFin handling :)
|
||||
seasonSection.style.cssText = `
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const scrollerContainer = seasonSection.querySelector('.emby-scroller');
|
||||
if (scrollerContainer) {
|
||||
scrollerContainer.classList.add('no-padding');
|
||||
}
|
||||
|
||||
const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer');
|
||||
if (sectionTitleContainer) {
|
||||
sectionTitleContainer.classList.add('no-padding');
|
||||
}
|
||||
|
||||
// Hide NextUp section if it exists
|
||||
const nextUpSection = activePage.querySelector('.nextUpSection');
|
||||
if (nextUpSection) {
|
||||
nextUpSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Insert episodes section right after NextUp section (or at the beginning if no NextUp)
|
||||
const detailPageContent = activePage.querySelector('.detailPageContent');
|
||||
if (detailPageContent) {
|
||||
if (nextUpSection && nextUpSection.parentNode) {
|
||||
nextUpSection.parentNode.insertBefore(seasonSection, nextUpSection.nextSibling);
|
||||
} else {
|
||||
// Find a good insertion point - after NextUp or before childrenCollapsible
|
||||
const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible') ||
|
||||
activePage.querySelector('#childrenCollapsible');
|
||||
if (childrenCollapsible && childrenCollapsible.parentNode) {
|
||||
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
|
||||
} else {
|
||||
detailPageContent.insertBefore(seasonSection, detailPageContent.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add season selector if more than one season
|
||||
if (seasons.length > 1) {
|
||||
const handleSeasonChange = async (selectedSeason) => {
|
||||
LOG(`Switching to season: ${selectedSeason.name}`);
|
||||
|
||||
const newEpisodes = await fetchEpisodesForSeries(seriesId, selectedSeason.id);
|
||||
if (newEpisodes.length === 0) {
|
||||
WARN(`No episodes found for Season ID: ${selectedSeason.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update section title
|
||||
const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle');
|
||||
if (sectionTitle) {
|
||||
const newSeasonName = formatSeasonName(selectedSeason.name, selectedSeason.indexNumber);
|
||||
sectionTitle.textContent = newSeasonName;
|
||||
}
|
||||
|
||||
// Update viewMoreUrl
|
||||
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
|
||||
if (titleLink && selectedSeason.id) {
|
||||
const newViewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${selectedSeason.id}&serverId=${serverId}`;
|
||||
titleLink.href = newViewMoreUrl;
|
||||
LOG(`Updated viewMoreUrl to: ${newViewMoreUrl}`);
|
||||
}
|
||||
|
||||
// Update season selector button
|
||||
const oldButton = sectionTitleContainer.querySelector('.season-selector-button');
|
||||
if (oldButton) {
|
||||
oldButton.remove();
|
||||
}
|
||||
createSeasonSelector(sectionTitleContainer, seasons, selectedSeason, handleSeasonChange);
|
||||
|
||||
// Replace episodes
|
||||
const itemsContainer = scrollerContainer?.querySelector('.itemsContainer');
|
||||
if (itemsContainer && window.cardBuilder && window.cardBuilder.buildCard) {
|
||||
itemsContainer.innerHTML = '';
|
||||
|
||||
newEpisodes.forEach(episode => {
|
||||
const card = window.cardBuilder.buildCard(episode, true);
|
||||
if (card) {
|
||||
itemsContainer.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if NextUp is in this season
|
||||
if (targetEpisodeNumber && targetEpisodeNumber.season === selectedSeason.indexNumber) {
|
||||
if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) {
|
||||
setTimeout(() => {
|
||||
const cards = itemsContainer.querySelectorAll('.card');
|
||||
for (const card of cards) {
|
||||
const cardTextLinks = card.querySelectorAll('.cardText a');
|
||||
for (const link of cardTextLinks) {
|
||||
const linkText = link.innerText || link.textContent;
|
||||
const match = linkText.match(/S(\d+):E(\d+)/);
|
||||
if (match &&
|
||||
parseInt(match[1], 10) === targetEpisodeNumber.season &&
|
||||
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
|
||||
card.classList.add('nextUpEpisode');
|
||||
LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
scrollToEpisode(scrollerContainer, targetEpisodeNumber);
|
||||
} else {
|
||||
// Scroll to beginning if this season doesn't have NextUp
|
||||
if (scrollerContainer) {
|
||||
setTimeout(() => {
|
||||
scrollerContainer.scrollToPosition(0);
|
||||
LOG('Scrolled to beginning of season (not NextUp season)');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createSeasonSelector(sectionTitleContainer, seasons, targetSeason, handleSeasonChange);
|
||||
}
|
||||
|
||||
// Add NextUp episode styling and class if needed
|
||||
if (targetEpisodeNumber && scrollerContainer) {
|
||||
if (targetEpisodeNumber.season === targetSeason.indexNumber) {
|
||||
// Add CSS for nextUpEpisode class if not already added
|
||||
if (!document.getElementById('kefinTweaks-nextUpEpisode-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'kefinTweaks-nextUpEpisode-style';
|
||||
const escapedHeaderText = nextUpHeaderText.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
style.textContent = `
|
||||
.nextUpEpisode .cardScalable::after {
|
||||
content: '${escapedHeaderText}';
|
||||
position: absolute;
|
||||
transform: translateY(-100%);
|
||||
padding: 0.5em 1em;
|
||||
width: calc(100% - 2em);
|
||||
text-align: center;
|
||||
background: linear-gradient(90deg, transparent, black, transparent);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText);
|
||||
}
|
||||
|
||||
// Add nextUpEpisode class if not S1E1
|
||||
if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) {
|
||||
setTimeout(() => {
|
||||
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
|
||||
if (scrollerItemsContainer) {
|
||||
const cards = scrollerItemsContainer.querySelectorAll('.card');
|
||||
for (const card of cards) {
|
||||
const cardTextLinks = card.querySelectorAll('.cardText a');
|
||||
for (const link of cardTextLinks) {
|
||||
const linkText = link.innerText || link.textContent;
|
||||
const match = linkText.match(/S(\d+):E(\d+)/);
|
||||
if (match &&
|
||||
parseInt(match[1], 10) === targetEpisodeNumber.season &&
|
||||
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
|
||||
card.classList.add('nextUpEpisode');
|
||||
LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
scrollToEpisode(scrollerContainer, targetEpisodeNumber);
|
||||
}
|
||||
}
|
||||
|
||||
LOG(`Successfully rendered episodes section for season ${targetSeason.indexNumber} with ${episodes.length} episodes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a series page to add episodes section
|
||||
* @param {string} seriesId - The series ID
|
||||
*/
|
||||
async function processSeriesPage(seriesId) {
|
||||
const activePage = document.querySelector('.libraryPage:not(.hide)');
|
||||
if (!activePage) {
|
||||
WARN('Active page not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already processed
|
||||
if (activePage.dataset.seriesEpisodesProcessed === 'true') {
|
||||
LOG('Series page already processed, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for seasons section to render
|
||||
LOG('Waiting for seasons section to render...');
|
||||
const childrenCollapsible = await waitForSeasonsSection(activePage);
|
||||
|
||||
if (!childrenCollapsible) {
|
||||
WARN('Could not find seasons section, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract seasons from DOM
|
||||
const seasons = extractSeasonsFromDOM(activePage);
|
||||
if (seasons.length === 0) {
|
||||
WARN('No seasons found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get configuration
|
||||
const config = window.KefinTweaksConfig || {};
|
||||
const scripts = config.scripts || {};
|
||||
const flattenConfig = config.flattenSingleSeasonShows || {};
|
||||
const isScriptEnabled = scripts.flattenSingleSeasonShows !== false; // Default to true if not set
|
||||
const hideSingleSeasonContainer = flattenConfig.hideSingleSeasonContainer === true;
|
||||
|
||||
// Only proceed if script is enabled or if single season
|
||||
const isSingleSeason = seasons.length === 1;
|
||||
if (!isSingleSeason && !isScriptEnabled) {
|
||||
LOG('Multi-season show and Episodes On Series Page script is disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get NextUp episode
|
||||
let targetEpisodeNumber = await fetchNextUpEpisode(seriesId);
|
||||
|
||||
// Determine target season
|
||||
let targetSeason = null;
|
||||
|
||||
if (targetEpisodeNumber) {
|
||||
// Find season matching NextUp episode by index number
|
||||
targetSeason = seasons.find(s => s.indexNumber === targetEpisodeNumber.season);
|
||||
}
|
||||
|
||||
// Fallback to first season if NextUp not found or season not in list
|
||||
if (!targetSeason) {
|
||||
targetSeason = seasons[0];
|
||||
// If we have NextUp but season not found, use first episode of first season
|
||||
if (targetEpisodeNumber && !seasons.find(s => s.indexNumber === targetEpisodeNumber.season)) {
|
||||
targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 };
|
||||
LOG(`NextUp season not in seasons list, defaulting to first episode of first season`);
|
||||
} else if (!targetEpisodeNumber) {
|
||||
targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 };
|
||||
LOG(`No NextUp found, defaulting to first episode of first season`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target season is at index 0 in itemsContainer and if it's specials (IndexNumber === 0)
|
||||
const itemsContainer = childrenCollapsible.querySelector('.itemsContainer');
|
||||
if (itemsContainer && targetSeason) {
|
||||
const firstCard = itemsContainer.querySelector(':scope > .card');
|
||||
if (firstCard) {
|
||||
const firstCardSeasonId = firstCard.getAttribute('data-id');
|
||||
// If target season is the first card in itemsContainer, check if it's specials
|
||||
if (firstCardSeasonId === targetSeason.id) {
|
||||
const seasonData = await fetchSeasonData(targetSeason.id);
|
||||
if (seasonData && seasonData.IndexNumber === 0) {
|
||||
LOG(`Target season is specials (IndexNumber 0), skipping to next season`);
|
||||
// Find the next season in the seasons array
|
||||
const currentIndex = seasons.findIndex(s => s.id === targetSeason.id);
|
||||
if (currentIndex !== -1 && currentIndex < seasons.length - 1) {
|
||||
targetSeason = seasons[currentIndex + 1];
|
||||
// Update targetEpisodeNumber to match the new season
|
||||
targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 };
|
||||
LOG(`Using next season instead: ${targetSeason.name} (IndexNumber: ${targetSeason.indexNumber})`);
|
||||
} else {
|
||||
WARN('Target season is specials but no next season found');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract NextUp header text
|
||||
let nextUpHeaderText = 'Next Up';
|
||||
const nextUpSection = activePage.querySelector('.nextUpSection');
|
||||
if (nextUpSection) {
|
||||
const nextUpHeader = nextUpSection.querySelector('.sectionTitle, h2, h3');
|
||||
if (nextUpHeader) {
|
||||
nextUpHeaderText = nextUpHeader.textContent.trim() || 'Next Up';
|
||||
LOG(`Extracted NextUp header text: "${nextUpHeaderText}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Render episodes section
|
||||
await renderEpisodesSection(seriesId, seasons, targetSeason, targetEpisodeNumber, nextUpHeaderText);
|
||||
|
||||
// Hide season container if option is enabled and there's only one season
|
||||
if (hideSingleSeasonContainer && isSingleSeason && childrenCollapsible) {
|
||||
childrenCollapsible.style.display = 'none';
|
||||
LOG('Hid season container for single-season show');
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
activePage.dataset.seriesEpisodesProcessed = 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the series episodes hook
|
||||
*/
|
||||
function initializeSeriesEpisodesHook() {
|
||||
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
|
||||
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
|
||||
setTimeout(initializeSeriesEpisodesHook, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG('Registering series episodes handler with KefinTweaksUtils');
|
||||
|
||||
window.KefinTweaksUtils.onViewPage(
|
||||
async (view, element, hash, itemPromise) => {
|
||||
const activePage = document.querySelector('.libraryPage:not(.hide)');
|
||||
if (!activePage) return;
|
||||
|
||||
// Remove processed flag when page changes
|
||||
if (activePage.dataset.seriesEpisodesProcessed) {
|
||||
//delete activePage.dataset.seriesEpisodesProcessed;
|
||||
LOG('Existing episodes section found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing episodes section
|
||||
const existingSection = activePage.querySelector('.series-episodes-section');
|
||||
if (existingSection) {
|
||||
//existingSection.remove();
|
||||
LOG('Existing episodes section found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await itemPromise;
|
||||
|
||||
if (item && item.Type === 'Series') {
|
||||
LOG(`Found series: ${item.Id} (${item.Name})`);
|
||||
|
||||
// Small delay to ensure page is ready
|
||||
setTimeout(async () => {
|
||||
await processSeriesPage(item.Id);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
{
|
||||
pages: ['details']
|
||||
}
|
||||
);
|
||||
|
||||
LOG('Series episodes hook initialized');
|
||||
}
|
||||
|
||||
// Initialize the hook when the script loads
|
||||
function initialize() {
|
||||
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
|
||||
WARN('KefinTweaksUtils not available, retrying in 1 second');
|
||||
setTimeout(initialize, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
initializeSeriesEpisodesHook();
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
LOG('Script loaded successfully');
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
// 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);
|
||||
|
||||
/**
|
||||
* 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<number>} 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: true
|
||||
});
|
||||
|
||||
// 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: true
|
||||
});
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds series info to the itemMiscInfo container
|
||||
* @param {Object} item - Series item object
|
||||
* @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container
|
||||
*/
|
||||
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';
|
||||
|
||||
// Seasons count (only show if more than 1 season)
|
||||
const childCount = item.ChildCount || 0;
|
||||
if (childCount > 1) {
|
||||
const seasonsDiv = document.createElement('div');
|
||||
seasonsDiv.className = 'mediaInfoItem';
|
||||
seasonsDiv.textContent = `${childCount} Seasons`;
|
||||
infoContainer.appendChild(seasonsDiv);
|
||||
}
|
||||
|
||||
// Episodes count
|
||||
const RecursiveItemCount = item.RecursiveItemCount || 0;
|
||||
if (RecursiveItemCount > 0) {
|
||||
const episodesDiv = document.createElement('div');
|
||||
episodesDiv.className = 'mediaInfoItem';
|
||||
episodesDiv.textContent = `${RecursiveItemCount} ${RecursiveItemCount === 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 = item.ChildCount || 0;
|
||||
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') {
|
||||
await 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;
|
||||
}
|
||||
|
||||
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');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user