mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
359 lines
15 KiB
JavaScript
359 lines
15 KiB
JavaScript
// KefinTweaks Flatten Single Season Shows
|
|
// Flattens series with only 1 season to show episodes directly on the series details page
|
|
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
const LOG = (...args) => console.log('[KefinTweaks FlattenSingleSeasonShows]', ...args);
|
|
const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args);
|
|
const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...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 all episodes for a given series
|
|
* @param {string} seriesId - The series ID
|
|
* @returns {Promise<Array>} - Array of episode items
|
|
*/
|
|
async function fetchEpisodesForSeries(seriesId) {
|
|
try {
|
|
const apiClient = window.ApiClient;
|
|
const userId = apiClient.getCurrentUserId();
|
|
const serverUrl = apiClient.serverAddress();
|
|
const token = apiClient.accessToken();
|
|
|
|
LOG(`Fetching episodes for series: ${seriesId}`);
|
|
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();
|
|
const episodes = episodesData.Items || [];
|
|
|
|
LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`);
|
|
return episodes;
|
|
} catch (error) {
|
|
ERR(`Failed to fetch episodes for series ${seriesId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flattens a single-season show by creating a "Season 1" section with all episodes
|
|
* @param {string} seriesId - The series ID
|
|
*/
|
|
async function flattenShow(seriesId) {
|
|
LOG(`Flattening show: ${seriesId}`);
|
|
|
|
// Check if already flattened for this series (avoid duplicates)
|
|
const activePage = document.querySelector('.libraryPage:not(.hide)');
|
|
if (activePage && activePage.dataset.flattenedSeriesId === seriesId) {
|
|
LOG(`Show ${seriesId} already flattened, skipping`);
|
|
return;
|
|
}
|
|
|
|
// Check if flattened section already exists
|
|
const existingSection = activePage?.querySelector('.flattened-season-section');
|
|
if (existingSection) {
|
|
LOG(`Flattened section already exists, skipping`);
|
|
return;
|
|
}
|
|
|
|
// Get all episodes for the series
|
|
const episodes = await fetchEpisodesForSeries(seriesId);
|
|
if (episodes.length === 0) {
|
|
WARN(`No episodes found for series: ${seriesId}`);
|
|
return;
|
|
}
|
|
|
|
// Check for cardBuilder
|
|
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
|
|
WARN('cardBuilder.renderCards not available');
|
|
return;
|
|
}
|
|
|
|
// Find the detailPageContent
|
|
const detailPageContent = activePage?.querySelector('.detailPageContent');
|
|
if (!detailPageContent) {
|
|
WARN('detailPageContent not found');
|
|
return;
|
|
}
|
|
|
|
// Find the #childrenCollapsible div
|
|
const childrenCollapsible = detailPageContent.querySelector('#childrenCollapsible');
|
|
if (!childrenCollapsible) {
|
|
WARN('#childrenCollapsible not found');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get the season ID from the first episode's ParentId
|
|
const seasonId = episodes[0]?.SeasonId;
|
|
let viewMoreUrl = null;
|
|
|
|
if (seasonId) {
|
|
const apiClient = window.ApiClient;
|
|
const serverId = apiClient.serverId();
|
|
viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${seasonId}&serverId=${serverId}`;
|
|
LOG(`Setting viewMoreUrl to season details: ${viewMoreUrl}`);
|
|
} else {
|
|
WARN('Could not determine season ID from episodes');
|
|
}
|
|
|
|
// Check if Next Up section exists
|
|
const nextUpSection = activePage?.querySelector('.nextUpSection');
|
|
const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer');
|
|
|
|
// Extract episode info from Next Up if it exists
|
|
let targetEpisodeNumber = null;
|
|
if (nextUpSection && nextUpItemsContainer) {
|
|
LOG('Next Up section found, hiding nextUpSection');
|
|
|
|
// Get the existing card from Next Up and extract episode info
|
|
const existingCard = nextUpItemsContainer.querySelector('.card');
|
|
if (existingCard) {
|
|
// Find the .cardText a element that contains the episode number
|
|
const cardTextLinks = existingCard.querySelectorAll('.cardText a');
|
|
for (const link of cardTextLinks) {
|
|
const linkText = link.innerText || link.textContent;
|
|
// Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name")
|
|
const match = linkText.match(/S(\d+):E(\d+)/);
|
|
if (match) {
|
|
targetEpisodeNumber = {
|
|
season: parseInt(match[1], 10),
|
|
episode: parseInt(match[2], 10)
|
|
};
|
|
LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we couldn't find it from the DOM, fetch from API
|
|
if (!targetEpisodeNumber) {
|
|
LOG('Could not find episode number from DOM, fetching from API');
|
|
targetEpisodeNumber = await fetchNextUpEpisode(seriesId);
|
|
}
|
|
|
|
// Hide the entire Next Up section
|
|
nextUpSection.style.display = 'none';
|
|
}
|
|
|
|
// Render Season 1 section using renderCards (default behavior)
|
|
const seasonSection = window.cardBuilder.renderCards(
|
|
episodes,
|
|
'Season 1',
|
|
viewMoreUrl,
|
|
true, // overflowCard
|
|
null // cardFormat (use default)
|
|
);
|
|
|
|
// Mark as flattened section
|
|
seasonSection.classList.add('flattened-season-section');
|
|
seasonSection.setAttribute('data-flattened-section', 'true');
|
|
|
|
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');
|
|
}
|
|
|
|
// Prepend the Season 1 section right before #childrenCollapsible
|
|
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
|
|
|
|
// Check if childrenCollapsible has only one child in its itemsContainer, and hide it if so
|
|
const childrenItemsContainer = childrenCollapsible.querySelector('.itemsContainer');
|
|
if (childrenItemsContainer) {
|
|
LOG('childrenCollapsible contains only one child, hiding it');
|
|
childrenCollapsible.style.display = 'none';
|
|
}
|
|
|
|
// If we found a target episode from Next Up, scroll to it
|
|
if (targetEpisodeNumber && scrollerContainer) {
|
|
// Wait for cards to be rendered and positioned
|
|
setTimeout(() => {
|
|
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
|
|
if (!scrollerItemsContainer) {
|
|
WARN('Could not find itemsContainer in scroller');
|
|
return;
|
|
}
|
|
|
|
// Find the card that matches the target episode number
|
|
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;
|
|
}
|
|
|
|
// Scroll to make the target card the leftmost visible element
|
|
if (targetCard) {
|
|
const itemsContainer = scrollerItemsContainer;
|
|
const cardOffset = targetCard.offsetLeft;
|
|
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
|
|
|
|
// Calculate the scrollToPosition value
|
|
// The card offset minus padding gives us how much to scroll
|
|
const translateX = (cardOffset - scrollerPadding);
|
|
scrollerContainer.scrollToPosition(translateX);
|
|
|
|
// Enable the left scroll button so users can scroll back
|
|
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('Enabled left scroll button');
|
|
}
|
|
}
|
|
|
|
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`);
|
|
} else {
|
|
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Mark as flattened for this series to avoid duplicates
|
|
if (activePage) {
|
|
activePage.dataset.flattenedSeriesId = seriesId;
|
|
}
|
|
|
|
LOG(`Successfully flattened show ${seriesId} with ${episodes.length} episodes`);
|
|
} catch (error) {
|
|
ERR('Error rendering flattened season section:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the flatten single season shows hook
|
|
*/
|
|
function initializeFlattenHook() {
|
|
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
|
|
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
|
|
setTimeout(initializeFlattenHook, 1000);
|
|
return;
|
|
}
|
|
|
|
LOG('Registering flatten single season shows handler with KefinTweaksUtils');
|
|
|
|
window.KefinTweaksUtils.onViewPage(
|
|
async (view, element, hash, itemPromise) => {
|
|
// Only handle details pages
|
|
const activePage = document.querySelector('.libraryPage:not(.hide)');
|
|
if (!activePage) return;
|
|
|
|
// Remove any existing flattened section and reset flag when page changes
|
|
const existingSection = activePage.querySelector('.flattened-season-section');
|
|
if (existingSection) {
|
|
LOG('Flattened section already exists, skipping');
|
|
return;
|
|
}
|
|
|
|
// Await the item promise to get the actual item data
|
|
const item = await itemPromise;
|
|
|
|
// Check if item is a Series and has only 1 ChildCount
|
|
if (item && item.Type === 'Series' && item.ChildCount === 1) {
|
|
LOG(`Found single-season series: ${item.Id} (${item.Name})`);
|
|
|
|
// Small delay to ensure details DOM is ready
|
|
setTimeout(async () => {
|
|
await flattenShow(item.Id);
|
|
}, 100);
|
|
}
|
|
},
|
|
{
|
|
pages: ['details']
|
|
}
|
|
);
|
|
|
|
LOG('Flatten single season shows 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;
|
|
}
|
|
|
|
initializeFlattenHook();
|
|
}
|
|
|
|
// Start initialization
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initialize);
|
|
} else {
|
|
initialize();
|
|
}
|
|
|
|
LOG('Script loaded successfully');
|
|
})();
|
|
|