`;
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
document.body.appendChild(modal);
} catch (err) {
console.error('❌ playMovieTrailer error:', err);
alert("Failed to load trailer");
}
}
window.playMovieTrailer = playMovieTrailer;
// ========================================
// SERIES FEATURES
// ========================================
const JFC_SHOWS = {
seriesInCollections: new Set(),
currentMode: "all",
isIndexing: false,
totalSeriesCount: 0 // 👈 Add this
};
// ========================================
// CORE: BUILD SERIES INDEX (with caching)
// ========================================
/**
* Build index of series that are in collections
* Fetches all BoxSets, then checks which visible series are in them
* Uses cache if available and valid
*/
async function buildSeriesCollectionsIndex(forceRefresh = false) {
if (JFC_SHOWS.isIndexing) return;
JFC_SHOWS.isIndexing = true;
const { userId, accessToken } = getCredentials();
try {
// Get visible series cards on current page
const cards = document.querySelectorAll('#tvRecommendedPage #seriesTab [data-isfolder="true"][data-id][data-type="Series"]');
const visibleSeriesIds = new Set([...cards].map(c => c.dataset.id).filter(Boolean));
if (!visibleSeriesIds.size) {
consoleLog("📺 No series found on page");
JFC_SHOWS.isIndexing = false;
return;
}
// 👇 Check cache first (unless force refresh)
if (!forceRefresh) {
const cached = localStorage.getItem(CONFIG.seriesCollectionsCache);
if (cached) {
try {
const { allSeriesInCollections, timestamp } = JSON.parse(cached);
const cacheAge = Date.now() - timestamp;
if (cacheAge < CONFIG.showsCollectionsCacheDuration) {
consoleLog(`📦 Using cached series collections (${Math.floor(cacheAge / 1000)}s old)`);
// Filter cached data to only visible series
JFC_SHOWS.seriesInCollections.clear();
allSeriesInCollections.forEach(seriesId => {
if (visibleSeriesIds.has(seriesId)) {
JFC_SHOWS.seriesInCollections.add(seriesId);
}
});
consoleLog(`📦 Series in collections: ${JFC_SHOWS.seriesInCollections.size} / ${visibleSeriesIds.size}`);
JFC_SHOWS.isIndexing = false;
return;
}
} catch (e) {
console.warn("⚠️ Cache parse failed, rebuilding...");
}
}
}
// 👇 No cache or force refresh - fetch from server
consoleLog("🔄 Fetching series collections from server...");
// Step 1: Fetch all BoxSets (collections)
const boxSetsResponse = await fetch(
`/Users/${userId}/Items?IncludeItemTypes=BoxSet&Recursive=true`,
{ headers: { "X-Emby-Token": accessToken } }
);
if (!boxSetsResponse.ok) throw new Error("Failed to fetch BoxSets");
const boxSetsData = await boxSetsResponse.json();
const boxSets = boxSetsData.Items || [];
consoleLog(`📦 Found ${boxSets.length} BoxSets`);
// Step 2: For each BoxSet, fetch Series inside it
const allSeriesInCollections = new Set();
for (const boxSet of boxSets) {
const seriesResponse = await fetch(
`/Users/${userId}/Items?ParentId=${boxSet.Id}&IncludeItemTypes=Series`,
{ headers: { "X-Emby-Token": accessToken } }
);
if (!seriesResponse.ok) continue;
const seriesData = await seriesResponse.json();
const seriesInThisBoxSet = seriesData.Items || [];
// Add ALL series to cache (not just visible ones)
seriesInThisBoxSet.forEach(series => {
allSeriesInCollections.add(series.Id);
});
}
// 👇 Save to cache
localStorage.setItem(CONFIG.seriesCollectionsCache, JSON.stringify({
allSeriesInCollections: [...allSeriesInCollections],
timestamp: Date.now()
}));
consoleLog(`💾 Cached ${allSeriesInCollections.size} series in collections`);
// Filter to only visible series for current use
JFC_SHOWS.seriesInCollections.clear();
allSeriesInCollections.forEach(seriesId => {
if (visibleSeriesIds.has(seriesId)) {
JFC_SHOWS.seriesInCollections.add(seriesId);
}
});
consoleLog(`📦 Series in collections: ${JFC_SHOWS.seriesInCollections.size} / ${visibleSeriesIds.size}`);
} catch (error) {
console.error("❌ Failed to build series index:", error);
} finally {
JFC_SHOWS.isIndexing = false;
}
}
// ========================================
// CACHE MANAGEMENT FUNCTIONS
// ========================================
/**
* Clear series collections cache
* Call this when collections are modified
*/
function clearSeriesCollectionsCache() {
localStorage.removeItem(CONFIG.seriesCollectionsCache);
consoleLog("🗑️ Series collections cache cleared");
}
/**
* Force refresh series collections (ignores cache)
*/
async function refreshSeriesCollections() {
consoleLog("🔄 Force refreshing series collections...");
clearSeriesCollectionsCache();
await buildSeriesCollectionsIndex(true);
applySeriesFilter();
consoleLog("✅ Series collections refreshed");
}
// ========================================
// FILTER: APPLY SHOW/HIDE LOGIC
// ========================================
function applySeriesFilter(isChecked) {
const page = document.querySelector('#tvRecommendedPage'); consoleLog("applySeriesFilter...", JFC_SHOWS.currentMode);
if (!page) return;
// Only skip if not on Collections tab AND feature disabled
let indicatorShouldUpdate = true;
if (JFC_SHOWS.currentMode !== "collections" && !USER_CONFIG?.features?.noCollectionFilter) {
JFC_SHOWS.currentMode = "all"; // disable noCollection filtering on Shows tab
indicatorShouldUpdate = false;
}
const cards = document.querySelectorAll('#tvRecommendedPage #seriesTab [data-isfolder="true"][data-id][data-type="Series"]');
let visibleCount = 0;
let isCollection = false;
// Update total based on current cards (in case page changed)
JFC_SHOWS.totalSeriesCount = cards.length;
const noUpdateCardsOnChangefilter = JFC_SHOWS.currentMode === "collections"; consoleLog("noUpdateCardsOnChangefilter", noUpdateCardsOnChangefilter);
// Set page attribute based on current mode
page.dataset.jfcFilterMode = JFC_SHOWS.currentMode;
// Mark cards with collection status
cards.forEach(card => {
const seriesId = card.dataset.id;
if (!seriesId) return;
const inCollection = JFC_SHOWS.seriesInCollections.has(seriesId);
isCollection = isCollection || inCollection;
// Add data attribute to card for CSS targeting
// if (!noUpdateCardsOnChangefilter) {
card.dataset.jfcInCollection = inCollection ? "true" : "false";
// }
// Count visible based on current mode
let shouldBeVisible = true;
if (JFC_SHOWS.currentMode === "collections") {
shouldBeVisible = inCollection;
} else if (JFC_SHOWS.currentMode === "no-collections") {
shouldBeVisible = !inCollection;
}
if (shouldBeVisible) visibleCount++;
});
updateSeriesPaging(visibleCount);
consoleLog(`🎛 Series filter applied: ${JFC_SHOWS.currentMode} (${visibleCount} visible)`);
// show filter indicator
if (indicatorShouldUpdate) {
updateFilterIndicator(isCollection, isChecked);
}
}
function updateFilterIndicator(isCollection, isChecked) {
if (JFC_SHOWS.currentMode === "no-collections" && isCollection || JFC_SHOWS.currentMode === "collections" && isCollection && isChecked !== false) {
const indicatorContainer = document.querySelector('#tvRecommendedPage #seriesTab .flex .btnFilter-wrapper');
if (indicatorContainer) {
if (!indicatorContainer.querySelector('.filterIndicator')) {
indicatorContainer.insertAdjacentHTML('afterbegin', '
!
');
}
else{
indicatorContainer.querySelector('.filterIndicator').classList.remove('hide');
}
indicatorContainer.classList.add('btnFilterWithIndicator');
}
}
else if (isChecked === false) {
const indicatorContainer = document.querySelector('#tvRecommendedPage #seriesTab .flex .btnFilter-wrapper');
if (indicatorContainer && indicatorContainer.querySelector('.filterIndicator') && !isOtherFilterActive()) {
indicatorContainer.querySelector('.filterIndicator').classList.add('hide');
}
}
}
function isOtherFilterActive() {
return document.querySelectorAll('.filterDialogContent input.emby-checkbox:checked:not([data-filter="IsNotCollection"])').length > 0;
}
// ========================================
// CSS: INJECT FILTER STYLES
// ========================================
function injectSeriesFilterStyles() {
if (document.getElementById('jfcSeriesFilterStyles')) return;
const style = document.createElement('style');
style.id = 'jfcSeriesFilterStyles';
style.textContent = `
/* Hide series NOT in collections when Collections tab is active */
#tvRecommendedPage[data-jfc-filter-mode="collections"] [data-isfolder="true"][data-type="Series"][data-jfc-in-collection="false"] {
display: none !important;
}
/* Hide series IN collections when "No collections" filter is active */
#tvRecommendedPage[data-jfc-filter-mode="no-collections"] [data-isfolder="true"][data-type="Series"][data-jfc-in-collection="true"] {
display: none !important;
}
.filterIndicator {
pointer-events: none;
}
`;
document.head.appendChild(style);
consoleLog("✓ Series filter CSS injected");
}
function updateSeriesPaging(visibleOnPage) {
const topPaging = document.querySelector('#seriesTab .flex:has(.btnFilter) .paging span');
const bottomPaging = document.querySelector('#seriesTab > .flex:last-of-type .paging span');
// Use the total we counted from actual cards
const total = JFC_SHOWS.totalSeriesCount || visibleOnPage;
// Build new paging text
let newPagingText;
if (visibleOnPage === 0) {
newPagingText = `0 of ${total}`;
} else if (visibleOnPage === 1) {
newPagingText = `1 of ${total}`;
} else {
newPagingText = `1-${visibleOnPage} of ${total}`;
}
// Update both paging locations
if (topPaging) topPaging.textContent = newPagingText;
if (bottomPaging) bottomPaging.textContent = newPagingText;
consoleLog(`📄 Paging updated: ${newPagingText}`);
}
// ========================================
// UI: INJECT COLLECTIONS TAB
// ========================================
async function injectShowsCollectionsTab() {
const slider = document.querySelector('.headerTabs .emby-tabs-slider');
if (!slider || document.querySelector('#jfcShowsCollectionsTab')) return;
const isSeriesPage = await isSeriePage(true);
if (!isSeriesPage) return;
const showsTab = slider.querySelector('.emby-tab-button[data-index="0"]');
const suggestionsTab = slider.querySelector('.emby-tab-button[data-index="1"]');
if (!showsTab || !suggestionsTab) return;
const collectionsTab = suggestionsTab.cloneNode(true);
collectionsTab.id = "jfcShowsCollectionsTab";
collectionsTab.dataset.index = "0";
collectionsTab.querySelector('.emby-button-foreground').textContent = "Collections";
collectionsTab.addEventListener("click", async (e) => {
// 👇 Check if we're NOT on Shows tab content
const showsContent = document.querySelector('#tvRecommendedPage #seriesTab.is-active');
if (!showsContent) {
// Programmatically click Shows tab first to switch content
e.preventDefault();
showsTab.click();
// Wait for content to switch, then apply filter
setTimeout(async () => {
document.querySelectorAll('.headerTabs .emby-tab-button')
.forEach(t => t.classList.remove("emby-tab-button-active"));
collectionsTab.classList.add("emby-tab-button-active");
JFC_SHOWS.currentMode = "collections";
await buildSeriesCollectionsIndex();
applySeriesFilter(true);
}, 100);
return;
}
// Already on Shows content, just apply filter
document.querySelectorAll('.headerTabs .emby-tab-button')
.forEach(t => t.classList.remove("emby-tab-button-active"));
collectionsTab.classList.add("emby-tab-button-active");
JFC_SHOWS.currentMode = "collections";
await buildSeriesCollectionsIndex();
applySeriesFilter();
});
suggestionsTab.after(collectionsTab);
if (localStorage.getItem(CONFIG.storageKey) === "true") {
JFC_SHOWS.currentMode = "no-collections"
}
consoleLog("✓ Shows 'Collections' tab injected");
}
// ========================================
// UI: ATTACH "SHOWS" TAB RESET LISTENER
// ========================================
function attachShowsTabResetListener() {
const showsTab = document.querySelector('.headerTabs .emby-tab-button[data-index="0"]');
const collectionsTab = document.querySelector('#jfcShowsCollectionsTab');
if (!showsTab || showsTab.dataset.jfcAttached) return;
showsTab.dataset.jfcAttached = "true";
showsTab.addEventListener("click", () => {
// Only reset if Collections was active
if (collectionsTab && collectionsTab.classList.contains("emby-tab-button-active")) {
collectionsTab.classList.remove("emby-tab-button-active");
}
// Reset to show all series
JFC_SHOWS.currentMode = localStorage.getItem(CONFIG.storageKey) === "true" ? "no-collections" : "all";
applySeriesFilter();
consoleLog("🔄 Shows tab clicked → reset to 'all'");
});
}
function watchForSeriesTabChanges() {
const tabButtons = document.querySelectorAll('.headerTabs .emby-tab-button');
tabButtons.forEach(tab => {
// Skip Shows and Collections tabs (already handled)
if (tab.dataset.index === "0" || tab.id === "jfcShowsCollectionsTab") return;
if (tab.dataset.jfcResetAttached) return;
tab.dataset.jfcResetAttached = "true";
tab.addEventListener("click", () => {
// Reset filter when switching to other tabs
const page = document.querySelector('#tvRecommendedPage');
if (page) {
page.dataset.jfcFilterMode = localStorage.getItem(CONFIG.storageKey) === "true" ? "no-collections" : "all";
JFC_SHOWS.currentMode = page.dataset.jfcFilterMode;
if (document.querySelector('#tvRecommendedPage #jfcUpcomingSeriesContent')) {
injectSeriesUpcomingSubTabs();
}
consoleLog("🔄 Switched to different tab → filter reset");
}
});
});
}
// ========================================
// WATCHER: DETECT NEW SERIES CARDS LOADED
// ========================================
let seriesPageObserver = null;
let applyFilterDebounce = null;
function watchSeriesPage() {
if (!location.hash.startsWith("#/tv?")) return;
const checkForPage = setInterval(async () => {
const page = document.querySelector("#tvRecommendedPage");
const seriesTab = document.querySelector("#seriesTab");
const grid = seriesTab?.querySelector(".itemsContainer");
if (!page || !grid) return;
clearInterval(checkForPage);
consoleLog("📺 Series page detected");
JFC_SHOWS.currentMode = localStorage.getItem(CONFIG.storageKey) === "true" ? "no-collections" : "all";
// 👇 Capture total from actual cards
captureTotalSeriesCount();
// Initial index build
await buildSeriesCollectionsIndex();
applySeriesFilter();
// Watch for new cards being added (pagination, scrolling, etc.)
if (seriesPageObserver) seriesPageObserver.disconnect();
seriesPageObserver = new MutationObserver(() => {
// Debounce: wait 200ms after last change
clearTimeout(applyFilterDebounce);
applyFilterDebounce = setTimeout(async () => {
captureTotalSeriesCount(); // 👈 Recapture when cards change
await buildSeriesCollectionsIndex();
applySeriesFilter();
}, 200);
});
seriesPageObserver.observe(grid, { childList: true, subtree: true });
consoleLog("👁 Series page observer active");
}, 500);
setTimeout(() => clearInterval(checkForPage), 15000);
}
function captureTotalSeriesCount() {
// Don't capture from paging element - count actual cards instead
const cards = document.querySelectorAll('#tvRecommendedPage #seriesTab [data-isfolder="true"][data-id][data-type="Series"]');
JFC_SHOWS.totalSeriesCount = cards.length;
consoleLog(`📊 Total series on current page: ${JFC_SHOWS.totalSeriesCount}`);
}
// ========================================
// INITIALIZATION
// ========================================
function initSeriesFeatures() {
if (!location.hash.startsWith("#/tv?")) return;
consoleLog("🎬 Initializing Series Collections feature...");
injectSeriesFilterStyles(); // 👈 Inject CSS first
// if (USER_CONFIG.features.noCollectionFilter && USER_CONFIG.features.seriesCollectionsTab) {
// }
if (USER_CONFIG.features.seriesCollectionsTab) {
injectShowsCollectionsTab();
attachShowsTabResetListener();
watchForSeriesTabChanges();
}
watchSeriesPage();
if (USER_CONFIG.features.upcomingSeries) {
watchSeriesUpcomingTab();
}
}
// ========================================
// UPCOMING SERIES FEATURE (TMDB Version)
// ========================================
window.JFC_UPCOMING = {
cache: null,
cacheTimestamp: 0,
isLoading: false,
currentTabView: "library", // "library" | "trending" | "toprated"
tmdbApiKey: null, // Will be set by user
trendingVisible: 30,
topRatedVisible: 30,
renderUpcomingNeeded: false
};
let trendingAll = [];
let topRatedSeriesAll = [];
let trendingVisible = 30;
let topRatedVisible = 30;
let missingEpisodesTimer = null;
const COMINGSOON_MAX = USER_CONFIG.data.comingSoonLimit;
const TOPRATED_MAX = USER_CONFIG.data.topRatedLimit;
const TRENDING_MAX = USER_CONFIG.data.trendingLimit;
const TMDB_GENRES = {
// Series genres
10759: "Action & Adventure",
16: "Animation",
35: "Comedy",
80: "Crime",
99: "Documentary",
18: "Drama",
10751: "Family",
10762: "Kids",
9648: "Mystery",
10763: "News",
10764: "Reality",
10765: "Sci-Fi & Fantasy",
10766: "Soap",
10767: "Talk",
10768: "War & Politics",
37: "Western",
// Movie-specific genres
28: "Action",
12: "Adventure",
14: "Fantasy",
36: "History",
27: "Horror",
10402: "Music",
10749: "Romance",
878: "Science Fiction",
10770: "TV Movie",
53: "Thriller",
10752: "War"
};
// Get unique genre names for series (from your original list)
const SERIES_GENRES = [
"Action & Adventure",
"Animation",
"Comedy",
"Crime",
"Documentary",
"Drama",
"Family",
"Kids",
"Mystery",
"News",
"Reality",
"Sci-Fi & Fantasy",
"Soap",
"Talk",
"War & Politics",
"Western"
];
// Get unique genre names for movies
const MOVIES_GENRES = [
"Action",
"Adventure",
"Animation",
"Comedy",
"Crime",
"Documentary",
"Drama",
"Family",
"Fantasy",
"History",
"Horror",
"Music",
"Mystery",
"Romance",
"Science Fiction",
"TV Movie",
"Thriller",
"War",
"Western"
];
let defaultsMoviesUpcomingGenres = {
'Action': true,
'Adventure': true,
'Animation': true,
'Comedy': true,
'Crime': true,
'Documentary': true,
'Drama': true,
'Family': true,
'Fantasy': true,
'History': true,
'Horror': true,
'Music': true,
'Mystery': true,
'Romance': true,
'Science Fiction': true,
'TV Movie': true,
'Thriller': true,
'War': true,
'Western': true
};
let defaultsSeriesUpcomingGenres = {
'Action & Adventure': true,
'Animation': true,
'Comedy': true,
'Crime': true,
'Documentary': true,
'Drama': true,
'Family': true,
'Kids': true,
'Mystery': true,
'News': true,
'Reality': true,
'Sci-Fi & Fantasy': true,
'Soap': true,
'Talk': true,
'War & Politics': true,
'Western': true
};
let selectedSeriesUpcomingGenres;
let selectedMoviesUpcomingGenres;
try {
selectedSeriesUpcomingGenres = JSON.parse(localStorage.getItem(CONFIG.upcomingSeriesGenreFilter));
selectedMoviesUpcomingGenres = JSON.parse(localStorage.getItem(CONFIG.upcomingMoviesGenreFilter));
JFC_UPCOMING.cache = JSON.parse(localStorage.getItem(CONFIG.upcomingSeriesCache));
} catch {
selectedSeriesUpcomingGenres = null;
selectedMoviesUpcomingGenres = null;
}
// Initialize with all checked if no saved state
if (!selectedSeriesUpcomingGenres || typeof selectedSeriesUpcomingGenres !== 'object') {
selectedSeriesUpcomingGenres = {};
SERIES_GENRES.forEach(genre => selectedSeriesUpcomingGenres[genre] = true);
}
if (!selectedMoviesUpcomingGenres || typeof selectedMoviesUpcomingGenres !== 'object') {
selectedMoviesUpcomingGenres = {};
MOVIES_GENRES.forEach(genre => selectedMoviesUpcomingGenres[genre] = true);
}
function loadCache() {
try { return JSON.parse(localStorage.getItem(CONFIG.missingSeriesCache)) || {}; }
catch { return {}; }
}
function saveCache(cache) {
localStorage.setItem(CONFIG.missingSeriesCache, JSON.stringify(cache));
}
function isMissingSeriesCacheExpired(entry) {
return !entry?.timestamp || (Date.now() - entry.timestamp > CONFIG.missingSeriesCacheTTL);
}
function getFreshCache() {
const cache = loadCache();
const oneExpired = Object.values(cache).some(isMissingSeriesCacheExpired);
if (oneExpired) {
localStorage.removeItem(CONFIG.missingSeriesCache);
return {};
}
return cache;
}
async function checkSeasonWithCache(seriesId, seasonId, seasonNumber) {
const cache = getFreshCache();
const entry = cache[seriesId];
const local = await getLocalEpisodeCount(seasonId);
// If we already know this exact local state
if (entry?.episodes?.[seasonNumber]?.local === local) {
const online = entry.episodes[seasonNumber].online;
consoleLog("📦 cache hit (season)", { local, online });
return { local, online, fromCache: true, cache };
}
// else → force online refresh
return { local, online: null, fromCache: false, cache };
}
async function checkSeriesWithCache(seriesId) {
const cache = getFreshCache();
const entry = cache[seriesId];
const local = await getLocalSeasonCount(seriesId);
if (entry?.seasons?.local === local) {
const online = entry.seasons.online;
consoleLog("📦 cache hit (series)", { local, online });
return { local, online, fromCache: true, cache };
}
return { local, online: null, fromCache: false, cache };
}
function updateSeasonCache(cache, seriesId, seasonNumber, local, online) {
cache[seriesId] ??= { seasons: {}, episodes: {} };
cache[seriesId].episodes[seasonNumber] = { local, online };
cache[seriesId].timestamp = Date.now();
saveCache(cache);
}
function updateSeriesCache(cache, seriesId, local, online) {
cache[seriesId] ??= { seasons: {}, episodes: {} };
cache[seriesId].seasons = { local, online };
cache[seriesId].timestamp = Date.now();
saveCache(cache);
}
// ========================================
// DETECT: Active Card Style
// ========================================
function detectActiveCardStyle(isTV = false) {
const container = document.querySelector(`${isTV ? '#tvRecommendedPage' : '#moviesPage'} .itemsContainer`);
if (!container) return 'thumbCard'; // Default fallback
// Check for each card type in order
if (container.querySelector('.card.bannerCard')) {
return 'banner';
} else if (container.querySelector('.listItem')) {
return 'list';
} else if (container.querySelector('.card.portraitCard:has(.visualCardBox)')) {
return 'poster';
} else if (container.querySelector('.card.portraitCard:has(.cardBox-bottompadded)')) {
return 'posterCard';
} else if (container.querySelector('.card.backdropCard:has(.visualCardBox)')) {
return 'thumb';
} else if (container.querySelector('.card.backdropCard:has(.cardBox-bottompadded)')) {
return 'thumbCard';
}
return 'thumbCard'; // Default
}
function getCardClasses(style) {
switch(style) {
case 'banner':
return {
card: 'card bannerCard card-hoverable bannerCard-scalable',
scalable: 'cardScalable',
padder: 'cardPadder-banner',
image: 'cardImage',
footer: 'cardFooter',
hasVisualBox: false
};
case 'list':
return {
card: 'listItem',
isListView: true
};
case 'poster':
return {
card: 'card portraitCard card-hoverable portraitCard-scalable',
scalable: 'cardScalable',
padder: 'cardPadder-portrait',
image: 'cardImage',
footer: 'cardFooter',
hasVisualBox: true
};
case 'posterCard':
return {
card: 'card portraitCard card-hoverable portraitCard-scalable',
scalable: 'cardScalable',
padder: 'cardPadder-portrait',
image: 'cardImage',
footer: 'cardFooter',
hasVisualBox: false
};
case 'thumb':
return {
card: 'card backdropCard card-hoverable backdropCard-scalable',
scalable: 'cardScalable',
padder: 'cardPadder-backdrop',
image: 'cardImage',
footer: 'cardFooter',
hasVisualBox: true
};
case 'thumbCard':
default:
return {
card: 'card backdropCard card-hoverable backdropCard-scalable',
scalable: 'cardScalable',
padder: 'cardPadder-backdrop',
image: 'cardImage',
footer: 'cardFooter',
hasVisualBox: false
};
}
}
// ========================================
// FETCH: Library Series Upcoming Episodes
// ========================================
async function fetchLibraryUpcomingEpisodes() {
consoleLog("📺 Fetching upcoming episodes for library series...");
const { userId, accessToken } = getCredentials();
// if (!tmdbKey) {
// console.warn("⚠️ TMDB API key not set. Use setTMDBApiKey() first.");
// return [];
// }
try {
// Step 1: Get all series in library
const libraryRes = await fetch(
`/Users/${userId}/Items?IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds`,
{ headers: { "X-Emby-Token": accessToken } }
);
if (!libraryRes.ok) throw new Error("Failed to fetch library");
const libraryData = await libraryRes.json();
const mySeries = libraryData.Items || [];
consoleLog(`📚 Found ${mySeries.length} series in library`);
const now = new Date();
const upcoming = [];
// 🚀 Helper function to process a single series
const processOneSeries = async (series) => {
const tmdbId = series.ProviderIds?.Tmdb;
if (!tmdbId) return null;
const localPosterPath = series.ImageTags?.Primary
? `/Items/${series.Id}/Images/Primary?maxHeight=1000`
: null;
try {
// Get series details from TMDB
const showData = await secureTMDBFetch(`tv/${tmdbId}`, {
'append_to_response': 'content_ratings'
});
// CASE 1: Has upcoming episode
if (showData.next_episode_to_air) {
const nextEp = showData.next_episode_to_air;
const airdate = new Date(nextEp.air_date);
if (airdate >= now) {
return {
tmdbId: tmdbId,
seriesId: series.Id,
itemId: series.Id, // alias for consistency in rendering section (renderUpcomingEpisodes)
seriesName: series.Name,
episodeName: nextEp.name,
season: nextEp.season_number,
episode: nextEp.episode_number,
airdate: nextEp.air_date,
airdateObj: airdate,
summary: nextEp.overview,
image: nextEp.still_path ?
`https://image.tmdb.org/t/p/w500${nextEp.still_path}` :
(showData.backdrop_path ? `https://image.tmdb.org/t/p/w500${showData.backdrop_path}` : null),
localPosterPath: localPosterPath,
runtime: nextEp.runtime || showData.episode_run_time?.[0],
rating: showData.vote_average,
inLibrary: true,
hasUpcoming: true
};
}
}
// CASE 2: No upcoming, check for missing latest
const lastSeason = (showData.seasons || [])
.filter(s => s.season_number > 0)
.sort((a, b) => b.season_number - a.season_number)[0];
if (!lastSeason) return null;
// Fetch just the last season
const seasonData = await secureTMDBFetch(`tv/${tmdbId}/season/${lastSeason.season_number}`, {});
// Find latest aired episode (reverse for speed)
const episodes = (seasonData.episodes || []).reverse();
let latest = null;
for (const ep of episodes) {
if (ep.air_date && new Date(ep.air_date) <= now) {
latest = { ...ep, season_number: lastSeason.season_number };
break;
}
}
if (!latest) return null;
// Check ownership
const alreadyOwned = await userHasEpisode(
series.Id,
latest.season_number,
latest.episode_number,
accessToken
);
if (alreadyOwned) return null;
return {
tmdbId: tmdbId,
seriesId: series.Id,
itemId: series.Id, // alias for consistency in rendering section (renderUpcomingEpisodes)
seriesName: series.Name,
episodeName: latest.name,
season: latest.season_number,
episode: latest.episode_number,
airdate: latest.air_date,
airdateObj: latest.air_date? new Date(latest.air_date) : null,
summary: latest.overview,
image: latest.still_path
? `https://image.tmdb.org/t/p/w500${latest.still_path}`
: (showData.backdrop_path
? `https://image.tmdb.org/t/p/w500${showData.backdrop_path}`
: null),
localPosterPath: localPosterPath,
runtime: latest.runtime || showData.episode_run_time?.[0],
rating: showData.vote_average,
inLibrary: true,
hasUpcoming: false,
isMissingLatest: true
};
} catch (e) {
console.warn(`⚠️ Failed to process ${series.Name}:`, e);
return null;
}
};
// 🚀 Process in parallel batches (TMDB allows 40 req/10s, so ~4 per second is safe)
const BATCH_SIZE = 4;
let processed = 0;
for (let i = 0; i < mySeries.length; i += BATCH_SIZE) {
const batch = mySeries.slice(i, i + BATCH_SIZE);
// Process batch in parallel
const batchResults = await Promise.all(
batch.map(series => processOneSeries(series))
);
// Collect non-null results
batchResults.forEach(result => {
if (result) upcoming.push(result);
});
processed += batch.length;
consoleLog(`⏳ Processed ${processed}/${mySeries.length} series...`);
// Wait 1 second between batches (ensures we stay under 40 req/10s limit)
if (i + BATCH_SIZE < mySeries.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
consoleLog(`✅ Processed ${processed} series, found ${upcoming.length} upcoming episodes`);
// Sort by airdate
upcoming.sort((a, b) => a.airdateObj - b.airdateObj);
return upcoming;
} catch (error) {
console.error("❌ Error fetching library upcoming:", error);
return [];
}
}
async function userHasEpisode(seriesId, season, episode, accessToken) {
const res = await fetch(
`/Users/${getCredentials().userId}/Items?ParentId=${seriesId}&IncludeItemTypes=Episode&Recursive=true&Filters=IsNotFolder&Fields=IndexNumber,ParentIndexNumber`,
{ headers: { "X-Emby-Token": accessToken } }
);
if (!res.ok) return false;
const data = await res.json();
const episodes = data.Items || [];
return episodes.some(ep =>
ep.ParentIndexNumber === season &&
ep.IndexNumber === episode
);
}
function getWeightedRating(show, m = 500, C = 7.0) {
const v = show.vote_count || 0;
const R = show.vote_average || 0;
if (v === 0) return 0;
return (v / (v + m)) * R + (m / (v + m)) * C;
}
// ========================================
// FETCH: Popular Shows (ALL, not just with upcoming)
// ========================================
async function fetchTrendingSeries(limit = 100) {
consoleLog("🔥 Fetching trending shows from TMDB...");
// if (!tmdbKey) return [];
try {
// 1️⃣ Get "trending but good" shows (discover = filters power)
const pages = [1, 2, 3, 4, 5];
const today = new Date().toISOString().split("T")[0];
const minYear = new Date().getFullYear() - 3; // shows that still feel "current"
const pageResults = await Promise.all(
pages.map(p =>
secureTMDBFetch('discover/tv', {
'language': 'en-US',
'sort_by': 'popularity.desc',
'vote_count.gte': '100',
'vote_average.gte': '7',
'first_air_date.gte': `${minYear}-01-01`,
'air_date.lte': today,
'page': p.toString()
}).then(d => d.results || [])
)
);
const allPopular = dedupeById(pageResults.flat(), 'id'); // ✅ Dedupe/Deduplication by TMDB id
consoleLog(`📊 Found ${allPopular.length} trending & filtered shows (base list)`);
// 2️⃣ Fetch show details in parallel (but not infinite)
const detailPromises = allPopular.slice(0, 40).map(show => // 40 is safe & fast & under TMDB limit
secureTMDBFetch(`tv/${show.id}`, {
'append_to_response': 'content_ratings'
}).catch(() => null)
);
const detailedShows = (await Promise.all(detailPromises)).filter(Boolean);
// 3️⃣ Build trending objects
let trending = detailedShows.map(showData => {
const nextEp = showData.next_episode_to_air; consoleLog("showData:", showData);
if (nextEp) {
return {
tmdbId: showData.id,
seriesName: showData.name,
episodeName: nextEp.name,
season: nextEp.season_number,
episode: nextEp.episode_number,
airdate: nextEp.air_date,
airdateObj: nextEp.air_date ? new Date(nextEp.air_date) : nextEp.firstAirDate ? new Date(nextEp.firstAirDate) : null,
summary: nextEp.overview,
image: nextEp.still_path
? `https://image.tmdb.org/t/p/w500${nextEp.still_path}`
: (showData.backdrop_path ? `https://image.tmdb.org/t/p/w500${showData.backdrop_path}` : null),
posterImage: showData.poster_path
? `https://image.tmdb.org/t/p/w500${showData.poster_path}` : null,
runtime: nextEp.runtime || showData.episode_run_time?.[0],
rating: showData.vote_average,
popularity: showData.popularity,
network: showData.networks?.[0]?.name,
status: showData.status,
genres: showData.genres.map(g => g.name),
inLibrary: false,
hasUpcoming: true,
isTrending: true
};
}
// fallback: no upcoming episode
const lastEp = showData.last_episode_to_air;
return {
tmdbId: showData.id,
seriesName: showData.name,
episodeName: lastEp?.name,
season: lastEp?.season_number,
episode: lastEp?.episode_number,
airdate: lastEp.air_date,
airdateObj: lastEp.air_date ? new Date(lastEp.air_date) : lastEp.firstAirDate ? new Date(lastEp.firstAirDate) : null,
summary: showData.overview,
image: showData.backdrop_path
? `https://image.tmdb.org/t/p/w500${showData.backdrop_path}` : null,
posterImage: showData.poster_path
? `https://image.tmdb.org/t/p/w500${showData.poster_path}` : null,
rating: showData.vote_average,
popularity: showData.popularity,
network: showData.networks?.[0]?.name,
status: showData.status,
genres: showData.genres.map(g => g.name),
firstAirDate: showData.first_air_date,
inLibrary: false,
hasUpcoming: false,
isTrending: true
};
});
// 4️⃣ Sort + limit
trending.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
// trending = trending.filter(show =>
// show.genres?.some(name => selectedSeriesUpcomingGenres[name])
// );
// Batch check library (single request)
const tmdbIds = trending.map(s => s.tmdbId);
const libraryMap = await batchCheckLibrary(tmdbIds, 'series');
trending.forEach((show, i) => {
show.inLibrary = libraryMap.has(String(show.tmdbId));
if (show.inLibrary) {
show.itemId = libraryMap.get(String(show.tmdbId)); // For linking
}
});
consoleLog(`🔥 Built ${trending.length} trending shows`);
return trending.slice(0, limit);
} catch (error) {
console.error("❌ Error fetching trending shows:", error);
return [];
}
}
// ========================================
// FETCH: Top Rated Series (Discovery)
// ========================================
async function fetchTopRatedSeries(limit = 100) {
consoleLog("📺 Fetching high-impact top rated series (experimental)...");
const TOPRATED_RULES = {
minVotes: 500,
minRating: 7.0,
maxAgeYears: 30
};
// if (!tmdbKey) return [];
try {
const currentYear = new Date().getFullYear();
const minYear = currentYear - TOPRATED_RULES.maxAgeYears;
// Calculate pages needed (TMDB returns 20 per page)
const pagesNeeded = Math.ceil(limit / 20);
// TMDB allows max 500 pages, but let's cap at 10 for safety (200 results)
const pagesToFetch = Math.min(pagesNeeded, 20);
const pages = Array.from({ length: pagesToFetch }, (_, i) => i + 1);
const allPromises = pages.map(page =>
secureTMDBFetch('discover/tv', {
'language': 'en-US',
'sort_by': 'popularity.desc',
'vote_count.gte': TOPRATED_RULES.minVotes.toString(),
'vote_average.gte': TOPRATED_RULES.minRating.toString(),
'first_air_date.gte': `${minYear}-01-01`,
'page': page.toString()
})
);
const results = await Promise.all(allPromises);
const flat = results.flatMap(r => r.results || []);
const allShows = dedupeById(flat, 'id'); // ✅ Dedupe/Deduplication by TMDB id
consoleLog(
`🎯 TMDB dedupe: ${flat.length} → ${allShows.length}`
);
consoleLog("📦 Raw discovered series:", allShows.length);
const formatted = allShows.map(show => {
const weightedRating = getWeightedRating(show, TOPRATED_RULES.minVotes, TOPRATED_RULES.minRating);
return {
tmdbId: show.id,
seriesName: show.name,
summary: show.overview,
image: show.backdrop_path
? `https://image.tmdb.org/t/p/w500${show.backdrop_path}`
: null,
posterImage: show.poster_path
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
: null,
rating: show.vote_average,
voteCount: show.vote_count,
popularity: show.popularity,
firstAirDate: show.first_air_date,
genres: (show.genre_ids || []).map(id => TMDB_GENRES[id]).filter(Boolean),
weightedRating,
impactScore:
(Math.log10(show.vote_count || 1) * 2) +
(Math.log10(show.popularity || 1)) +
(weightedRating * 3),
inLibrary: false,
trailerKey: null
};
});
const topRated = formatted
.filter(s => s.rating > 0 && s.voteCount >= TOPRATED_RULES.minVotes && s.rating >= TOPRATED_RULES.minRating && !s.adult) // filter out adult content but not used anyway
.sort((a, b) =>
(b.weightedRating - a.weightedRating) ||
(b.impactScore - a.impactScore) ||
(b.voteCount - a.voteCount)
)
.slice(0, limit);
// Batch check library (single request)
const tmdbIds = topRated.map(s => s.tmdbId);
const libraryMap = await batchCheckLibrary(tmdbIds, 'series');
topRated.forEach((show, i) => {
show.topIndex = i + 1;
show.isTopRated = true;
show.inLibrary = libraryMap.has(String(show.tmdbId));
if (show.inLibrary) {
show.itemId = libraryMap.get(String(show.tmdbId)); // For linking
}
});
topRated.forEach((show, i) => {
show.topIndex = i + 1; // 1..100
show.isTopRated = true;
});
consoleLog(`✅ Returning ${topRated.length} high-impact series`);
return topRated;
} catch (error) {
console.error("❌ Error fetching top rated series:", error);
return [];
}
}
// ========================================
// UPDATE: fetchAllUpcomingSeries to include top rated
// ========================================
async function fetchAllUpcomingSeries(forceRefresh = false) {
consoleLog("checking cache", forceRefresh, JFC_UPCOMING.cache, Date.now() - JFC_UPCOMING.cache?.cacheTimestamp, CONFIG.upcomingSeriesCacheDuration);
// Check cache
if (!forceRefresh && JFC_UPCOMING.cache &&
(Date.now() - JFC_UPCOMING.cache.cacheTimestamp) < CONFIG.upcomingSeriesCacheDuration) {
consoleLog("📦 Using cached upcoming data");
return JFC_UPCOMING.cache;
}
if (JFC_UPCOMING.isLoading) {
consoleLog("⏳ Already loading...");
return JFC_UPCOMING.cache || { library: [], trending: [], topRated: [] };
}
// Display message "no api key found"
if (!hasTMDB) {
showApiKeyWarning(document.querySelector('#jfcUpcomingSeriesContent'), 'series');
return { library: [], trendingAll: [], topRatedSeriesAll: [] };
}
JFC_UPCOMING.isLoading = true;
showSpinner();
try {
// Fetch all three in parallel
const [library, trendingAll, topRatedSeriesAll] = await Promise.all([
fetchLibraryUpcomingEpisodes(),
fetchTrendingSeries(TRENDING_MAX),
fetchTopRatedSeries(TOPRATED_MAX) // Get 30 top rated shows
]);
const result = { library, trendingAll, topRatedSeriesAll };
// Cache the result
JFC_UPCOMING.cache = result;
JFC_UPCOMING.cache.cacheTimestamp = Date.now();
localStorage.setItem(CONFIG.upcomingSeriesCache, JSON.stringify(JFC_UPCOMING.cache));
consoleLog(`💾 Cached ${library.length} library + ${trendingAll.length} trending + ${topRatedSeriesAll.length} top rated`);
return result;
} finally {
JFC_UPCOMING.isLoading = false;
showSpinner(false);
}
}
//const upcomingContainer = document.getElementById("jfcUpcomingSeriesContent");
function renderTrending(container) {
const filtered = applyUpcomingGenreFilter(trendingAll, 'series');
const max = Math.min(TRENDING_MAX, filtered.length);
const visible = filtered.slice(0, Math.min(trendingVisible, max));
renderUpcomingEpisodes(visible, container);
renderLoadMoreButton(container, "trending", visible.length, filtered.length);
}
function renderTopRated(container) {
const filtered = applyUpcomingGenreFilter(topRatedSeriesAll, 'series');
const max = Math.min(TOPRATED_MAX, filtered.length);
const visible = filtered.slice(0, Math.min(topRatedVisible, max));
renderUpcomingEpisodes(visible, container);
renderLoadMoreButton(container, "toprated", visible.length, filtered.length);
}
function applyUpcomingGenreFilter(list, type = 'series') { consoleLog("applyUpcomingGenreFilter", selectedSeriesUpcomingGenres, list);
const selectedGenres = type === 'movies' ? selectedMoviesUpcomingGenres : selectedSeriesUpcomingGenres;
if (!selectedGenres) return list;
const hasActiveFilter = Object.values(selectedGenres).some(v => v === true);
// If none checked → show everything
if (!hasActiveFilter) return list;
return list.filter(item => {
if (!item.genres || !item.genres.length) return true; // keep unknown
return item.genres.some(name => selectedGenres[name]);
});
}
function renderLoadMoreButton(container, type, visible, total) {
const max = type === "trending" ? TRENDING_MAX : TOPRATED_MAX;
const limit = Math.min(max, total);
if (visible >= limit) return;
const btnContainer = document.createElement("DIV");
btnContainer.classList.add("mainDetailButtons");
const btn = document.createElement("button");
btn.className = "loadMoreBtn emby-button raised";
btn.textContent = `Load more (${limit - visible})`;
btn.onclick = () => {
if (type === "trending") {
trendingVisible = limit;
renderTrending(container);
} else if (type === "toprated") {
topRatedVisible = limit;
renderTopRated(container);
}
};
btnContainer.appendChild(btn);
container?.appendChild(btnContainer);
}
// ========================================
// UI: RENDER UPCOMING EPISODES (with card style detection)
// ========================================
function renderUpcomingEpisodes(episodes, container) { consoleLog("renderUpcomingEpisodes", container);
if (!hasTMDB) return;
showSeriesUpcomingSubtabs();
if (!episodes || episodes.length === 0) {
container.innerHTML = `