diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index e486641..89df1ac 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -699,7 +699,7 @@ { name: 'Dreamworks Animation', file: 'DreamWorks_Animation.svg' }, { name: 'FX', file: 'FX.svg' }, { name: 'HBO', file: 'HBO.svg' }, - { name: 'Lucasfilm', file: 'Lucasfilm.svg' }, + { name: 'Lucasfilm Ltd.', file: 'Lucasfilm.svg' }, { name: 'Marvel Studios', file: 'Marvel_Studios.svg' }, { name: 'New Line Cinema', file: 'New_Line_Cinema.svg' }, { name: 'Paramount Pictures', file: 'Paramount_Pictures.svg' }, @@ -709,13 +709,44 @@ { name: 'Walt Disney Pictures', file: 'Walt_Disney_Pictures.svg' }, { name: 'Warner Bros.', file: 'Warner_Bros..svg' }, { name: 'HBO Max', file: 'HBO_Max.svg' }, - { name: 'Prime Video', file: 'Prime_Video.svg' } + { name: 'Prime Video', file: 'Prime_Video.svg' }, + { name: 'Hulu', file: 'Hulu.svg' }, + { name: 'Peacock', file: 'Peacock.svg' } ]; const BROWSE_BY_STUDIO_SECTION_ID = 'browse-by-studio'; const BROWSE_BY_STUDIO_GROUP_ID = 'home-popular-studios'; const BROWSE_BY_STUDIO_GROUP_NAME = 'Popular Studios'; + const RECENTLY_ADDED_GROUPED_MOVIES_ID = 'recently-added-grouped-movies'; + const RECENTLY_ADDED_GROUPED_TVSHOWS_ID = 'recently-added-grouped-tvshows'; + const RECENTLY_ADDED_GROUPED_SHELL_IDS = new Set([ + RECENTLY_ADDED_GROUPED_MOVIES_ID, + RECENTLY_ADDED_GROUPED_TVSHOWS_ID + ]); + + function buildGroupedRecentlyAddedShell(collectionType) { + const isMovies = collectionType === 'movies'; + return { + id: isMovies ? RECENTLY_ADDED_GROUPED_MOVIES_ID : RECENTLY_ADDED_GROUPED_TVSHOWS_ID, + name: isMovies ? 'Recently Added Movies' : 'Recently Added TV Shows', + enabled: false, + order: 61, + cardFormat: 'Poster', + jellyfinId: 'latestmedia', + userConfigurable: true, + queries: [{ + path: '/Items/Latest', + queryOptions: { + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb' + } + }] + }; + } + let browseStudiosFetchPromise = null; function isBrowseByStudioCatalogFullyResolved(existingItems) { @@ -937,6 +968,9 @@ const sectionsToRemove = groupSections.filter(s => { if (!s.id || !s.id.startsWith(template.idPrefix)) return false; + if (template.idPrefix === 'recently-added-' && RECENTLY_ADDED_GROUPED_SHELL_IDS.has(s.id)) { + return false; + } const libId = s.id.replace(template.idPrefix, ''); return !currentLibraryIds.has(libId); }); @@ -961,11 +995,42 @@ }); } + // Style-only grouped Movies/TV shells when multiple libraries of that type exist + { + const recentlyAddedGroup = updatedGroups.find( + (g) => g.id === 'home-recently-added' || g.name === 'Recently Added' + ); + if (recentlyAddedGroup) { + if (!Array.isArray(recentlyAddedGroup.sections)) { + recentlyAddedGroup.sections = []; + } + const movieCount = filteredLibraries.filter((l) => l.CollectionType === 'movies').length; + const tvCount = filteredLibraries.filter((l) => l.CollectionType === 'tvshows').length; + const shellSpecs = [ + { id: RECENTLY_ADDED_GROUPED_MOVIES_ID, want: movieCount > 1, collectionType: 'movies' }, + { id: RECENTLY_ADDED_GROUPED_TVSHOWS_ID, want: tvCount > 1, collectionType: 'tvshows' } + ]; + for (const spec of shellSpecs) { + const idx = recentlyAddedGroup.sections.findIndex((s) => s?.id === spec.id); + if (spec.want && idx === -1) { + recentlyAddedGroup.sections.push(buildGroupedRecentlyAddedShell(spec.collectionType)); + hasChanges = true; + LOG(`Added grouped recently-added shell: ${spec.id}`); + } else if (!spec.want && idx !== -1) { + recentlyAddedGroup.sections.splice(idx, 1); + hasChanges = true; + LOG(`Removed grouped recently-added shell: ${spec.id}`); + } + } + } + } + // Remove stale library sections from any HOME group (e.g. misplaced sections) for (const group of updatedGroups) { const beforeCount = (group.sections || []).length; group.sections = (group.sections || []).filter(s => { if (!s?.id) return true; + if (RECENTLY_ADDED_GROUPED_SHELL_IDS.has(s.id)) return true; for (const template of libraryTemplates) { if (s.id.startsWith(template.idPrefix)) { const libId = s.id.replace(template.idPrefix, ''); @@ -1002,6 +1067,20 @@ } const browseSection = studiosGroup.sections.find((s) => s?.id === BROWSE_BY_STUDIO_SECTION_ID); + const catalogNames = new Set(BROWSE_BY_STUDIO_CATALOG.map((entry) => entry.name)); + + // Prune items no longer in Kefin catalog (even when catalog is otherwise fully resolved) + if (browseSection && Array.isArray(browseSection.items)) { + const beforeLen = browseSection.items.length; + browseSection.items = browseSection.items.filter( + (item) => item?.Name && catalogNames.has(item.Name) + ); + if (browseSection.items.length !== beforeLen) { + hasChanges = true; + LOG(`Pruned ${beforeLen - browseSection.items.length} browse-by-studio studios not in catalog`); + } + } + const existingItems = Array.isArray(browseSection?.items) ? browseSection.items : []; if (browseSection && isBrowseByStudioCatalogFullyResolved(existingItems)) { @@ -1070,10 +1149,15 @@ const KEFIN_DEFAULT_GROUP_KEYS = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS']; const LIBRARY_RESOLVED_ID_PREFIXES = ['recently-added-', 'popular-genres-']; - const FIXED_TEMPLATE_SECTION_IDS = new Set([BROWSE_BY_STUDIO_SECTION_ID]); + const FIXED_TEMPLATE_SECTION_IDS = new Set([ + BROWSE_BY_STUDIO_SECTION_ID, + RECENTLY_ADDED_GROUPED_MOVIES_ID, + RECENTLY_ADDED_GROUPED_TVSHOWS_ID + ]); function isLibraryResolvedSectionId(sectionId) { if (!sectionId) return false; + if (RECENTLY_ADDED_GROUPED_SHELL_IDS.has(sectionId)) return true; return LIBRARY_RESOLVED_ID_PREFIXES.some((prefix) => sectionId.startsWith(prefix)); } diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js index 29f7e70..fcb2fac 100644 --- a/configuration/homeScreen-user-configuration.js +++ b/configuration/homeScreen-user-configuration.js @@ -908,6 +908,235 @@ return catalog.filter((s) => resolveSectionJellyfinId(s) === type); } + const RECENTLY_ADDED_PREFIX = 'recently-added-'; + const RECENTLY_ADDED_GROUPED_MOVIES_ID = 'recently-added-grouped-movies'; + const RECENTLY_ADDED_GROUPED_TVSHOWS_ID = 'recently-added-grouped-tvshows'; + const LATEST_EXCLUDE_VIEW_TYPES = new Set(['playlists', 'livetv', 'boxsets', 'channels', 'folders']); + + function isRecentlyAddedGroupedShellId(id) { + return id === RECENTLY_ADDED_GROUPED_MOVIES_ID || id === RECENTLY_ADDED_GROUPED_TVSHOWS_ID; + } + + function cloneRecentlyAddedSection(section) { + const next = { ...section }; + if (Array.isArray(section.queries)) { + next.queries = section.queries.map((q) => ({ + ...q, + queryOptions: q?.queryOptions ? { ...q.queryOptions } : {} + })); + } + return next; + } + + function applyHidePlayedInLatestToSection(section) { + if (!Array.isArray(section.queries)) return section; + section.queries = section.queries.map((q) => ({ + ...q, + queryOptions: { + ...(q.queryOptions || {}), + IsPlayed: false + } + })); + return section; + } + + function buildMinimalGroupedRecentlyAddedShell(collectionType) { + const isMovies = collectionType === 'movies'; + return { + id: isMovies ? RECENTLY_ADDED_GROUPED_MOVIES_ID : RECENTLY_ADDED_GROUPED_TVSHOWS_ID, + name: isMovies ? 'Recently Added Movies' : 'Recently Added TV Shows', + enabled: false, + order: 61, + cardFormat: 'Poster', + jellyfinId: 'latestmedia', + userConfigurable: true, + queries: [{ + path: '/Items/Latest', + queryOptions: { + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb' + } + }] + }; + } + + /** + * Per-library recently-added rows folded into a combined Movies/TV UserView + * (library Id no longer appears in eligible UserViews). + */ + function collectFoldedRecentlyAddedByType(sections, eligibleViewIds, libTypeById) { + const folded = { movies: [], tvshows: [] }; + (sections || []).forEach((section) => { + const id = String(section?.id || ''); + if (!id.startsWith(RECENTLY_ADDED_PREFIX) || isRecentlyAddedGroupedShellId(id)) return; + const libId = id.slice(RECENTLY_ADDED_PREFIX.length); + if (eligibleViewIds.has(libId)) return; + const ct = String(libTypeById.get(libId) || '').toLowerCase(); + if (ct === 'movies' || ct === 'tvshows') { + folded[ct].push(section); + } + }); + return folded; + } + + /** Default grouped enabled = true if any merged per-lib recently-added source is enabled. */ + function applyGroupedEnabledFromMergedSources(groupedSection, foldedSources) { + const sources = foldedSources || []; + if (!sources.length) return groupedSection; + groupedSection.enabled = sources.some((s) => s?.enabled === true); + return groupedSection; + } + + async function fetchUserViewsItems() { + if (!window.ApiClient?.getCurrentUserId) return []; + const userId = window.ApiClient.getCurrentUserId(); + if (window.ApiClient.getUserViews) { + const result = await window.ApiClient.getUserViews({}, userId); + if (Array.isArray(result)) return result; + return result?.Items || []; + } + return []; + } + + /** + * Reshape recently-added-* for the viewing user to match native Latest: + * UserViews membership, LatestItemsExcludes, grouped Movies/TV shells, HidePlayedInLatest. + * Returns in-memory clones only — does not persist queryOptions. + */ + async function resolveRecentlyAddedSectionsForUser(sections) { + const list = Array.isArray(sections) ? sections : []; + let user = null; + try { + user = await window.ApiClient?.getCurrentUser?.(); + } catch (e) { + WARN('getCurrentUser failed during recently-added reshape:', e); + } + + const hidePlayed = user?.Configuration?.HidePlayedInLatest === true; + const excludes = new Set( + (user?.Configuration?.LatestItemsExcludes || []).map((id) => String(id)) + ); + + let userViews = []; + try { + userViews = await fetchUserViewsItems(); + } catch (e) { + WARN('UserViews unavailable; applying HidePlayedInLatest only:', e); + if (!hidePlayed) return list; + return list.map((section) => { + if (!String(section?.id || '').startsWith(RECENTLY_ADDED_PREFIX)) return section; + return applyHidePlayedInLatestToSection(cloneRecentlyAddedSection(section)); + }); + } + + const physicalLibraryIds = new Set(); + const libTypeById = new Map(); + try { + if (window.dataHelper?.getLibraries) { + const libs = await window.dataHelper.getLibraries(); + (libs || []).forEach((lib) => { + if (lib?.Id) { + physicalLibraryIds.add(String(lib.Id)); + libTypeById.set(String(lib.Id), String(lib.CollectionType || '').toLowerCase()); + } + }); + } + } catch (_) { /* fall through */ } + if (physicalLibraryIds.size === 0) { + list.forEach((section) => { + const id = String(section?.id || ''); + if (!id.startsWith(RECENTLY_ADDED_PREFIX) || isRecentlyAddedGroupedShellId(id)) return; + physicalLibraryIds.add(id.slice(RECENTLY_ADDED_PREFIX.length)); + }); + } + + const eligibleViews = (userViews || []).filter((view) => { + if (!view?.Id) return false; + if (excludes.has(String(view.Id))) return false; + const ct = String(view.CollectionType || '').toLowerCase(); + if (ct && LATEST_EXCLUDE_VIEW_TYPES.has(ct)) return false; + return true; + }); + const eligibleViewIds = new Set(eligibleViews.map((view) => String(view.Id))); + + const combinedByType = new Map(); + eligibleViews.forEach((view) => { + const ct = String(view.CollectionType || '').toLowerCase(); + if ((ct === 'movies' || ct === 'tvshows') && !physicalLibraryIds.has(String(view.Id))) { + combinedByType.set(ct, view); + } + }); + + const foldedByType = collectFoldedRecentlyAddedByType(list, eligibleViewIds, libTypeById); + + const sectionById = new Map(); + list.forEach((section) => { + if (section?.id) sectionById.set(section.id, section); + }); + + const result = []; + let emittedGroupedMovies = false; + let emittedGroupedTv = false; + + function bindGroupedShell(ct, view, template) { + const shellId = ct === 'movies' ? RECENTLY_ADDED_GROUPED_MOVIES_ID : RECENTLY_ADDED_GROUPED_TVSHOWS_ID; + const next = cloneRecentlyAddedSection(template || buildMinimalGroupedRecentlyAddedShell(ct)); + next.id = shellId; + if (view.Name) next.name = `Recently Added ${view.Name}`; + if (Array.isArray(next.queries)) { + next.queries = next.queries.map((q) => ({ + ...q, + queryOptions: { + ...(q.queryOptions || {}), + ParentId: view.Id + } + })); + } + applyGroupedEnabledFromMergedSources(next, foldedByType[ct]); + if (hidePlayed) applyHidePlayedInLatestToSection(next); + return next; + } + + for (const section of list) { + const id = String(section?.id || ''); + if (!id.startsWith(RECENTLY_ADDED_PREFIX)) { + result.push(section); + continue; + } + + if (id === RECENTLY_ADDED_GROUPED_MOVIES_ID || id === RECENTLY_ADDED_GROUPED_TVSHOWS_ID) { + const ct = id === RECENTLY_ADDED_GROUPED_MOVIES_ID ? 'movies' : 'tvshows'; + const view = combinedByType.get(ct); + if (!view) continue; + const template = sectionById.get(id) || buildMinimalGroupedRecentlyAddedShell(ct); + result.push(bindGroupedShell(ct, view, template)); + if (ct === 'movies') emittedGroupedMovies = true; + else emittedGroupedTv = true; + continue; + } + + const libId = id.slice(RECENTLY_ADDED_PREFIX.length); + if (!eligibleViewIds.has(libId)) continue; + + const next = cloneRecentlyAddedSection(section); + if (hidePlayed) applyHidePlayedInLatestToSection(next); + result.push(next); + } + + // Combined view exists but admin shell was missing from catalog — append synthesized row + for (const [ct, view] of combinedByType) { + if (ct === 'movies' && emittedGroupedMovies) continue; + if (ct === 'tvshows' && emittedGroupedTv) continue; + const shellId = ct === 'movies' ? RECENTLY_ADDED_GROUPED_MOVIES_ID : RECENTLY_ADDED_GROUPED_TVSHOWS_ID; + const template = sectionById.get(shellId) || buildMinimalGroupedRecentlyAddedShell(ct); + result.push(bindGroupedShell(ct, view, template)); + } + + return result; + } + /** * Enable Kefin sections from native Jellyfin homesectionN (enable-only, no order changes). * If any candidate for a native type is already enabled, skip that type. @@ -1036,6 +1265,9 @@ } } + // Align recently-added-* with native Latest (UserViews, excludes, grouped, HidePlayedInLatest) + sections = await resolveRecentlyAddedSectionsForUser(sections); + sections = sections.filter((section) => section.enabled || section.userConfigurable === true || section.userConfigurable === undefined); sections = deduplicateHomeScreenSections(sections); diff --git a/pages/images/studios/Hulu.svg b/pages/images/studios/Hulu.svg new file mode 100644 index 0000000..f616925 --- /dev/null +++ b/pages/images/studios/Hulu.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Peacock.svg b/pages/images/studios/Peacock.svg new file mode 100644 index 0000000..51f8f00 --- /dev/null +++ b/pages/images/studios/Peacock.svg @@ -0,0 +1 @@ + \ No newline at end of file