Add support for native Grouped library and Exclude library from Latest/Resume sections, and add Peacock and Hulu to browse by studio section

This commit is contained in:
HighImKevin
2026-10-05 22:33:47 -04:00
parent b193368394
commit ec9daf9723
4 changed files with 329 additions and 3 deletions
+87 -3
View File
@@ -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));
}
@@ -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);
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 71.15">
<g style="fill:#ffffff">
<path d="M102.07,22.2c0,0,0,27.75,0,28.71c0,2.54-2.05,4.6-4.6,4.6H87.5c-2.54,0-4.6-2.06-4.6-4.6 c0-0.96,0-28.71,0-28.71H65.74v30.14c0,12.32,7.88,18.81,19.53,18.81H99.7c11.65,0,19.53-6.49,19.53-18.81V22.2H102.07z"/>
<path d="M198.85,22.2c0,0,0,27.75,0,28.71c0,2.54-2.05,4.6-4.6,4.6h-9.98c-2.54,0-4.6-2.06-4.6-4.6 c0-0.96,0-28.71,0-28.71h-17.16v30.14c0,12.32,7.88,18.81,19.53,18.81h14.42c11.65,0,19.53-6.49,19.53-18.81V22.2H198.85z"/>
<path d="M33.95,22.2c0,0-8.94,0-11,0c-3.86,0-5.8,1.04-5.8,1.04V0H0v71.15h17.15V42.44c0-2.54,2.06-4.6,4.6-4.6h9.98 c2.54,0,4.59,2.06,4.59,4.6v28.71l17.16,0V40.21C53.48,27.21,44.81,22.2,33.95,22.2z"/>
<rect x="132.3" width="17.16" height="71.15"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 806 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" data-testid="peacock-wob-logo" viewBox="0 0 1607 400"><g clip-path="url(#a)"><path fill="url(#b)" d="M1606.99 31.25c0 17.26-13.99 31.25-31.25 31.25s-31.25-13.99-31.25-31.25S1558.48 0 1575.74 0s31.25 13.99 31.25 31.25m-31.25 53.13c-17.26 0-31.25 13.99-31.25 31.25s13.99 31.25 31.25 31.25 31.25-13.99 31.25-31.25-13.99-31.25-31.25-31.25m0 84.37c-17.26 0-31.25 13.99-31.25 31.25s13.99 31.25 31.25 31.25 31.25-13.99 31.25-31.25-13.99-31.25-31.25-31.25m0 84.37c-17.26 0-31.25 13.99-31.25 31.25s13.99 31.25 31.25 31.25 31.25-13.99 31.25-31.25-13.99-31.25-31.25-31.25m0 84.38c-17.26 0-31.25 13.99-31.25 31.25s13.99 31.25 31.25 31.25 31.25-13.99 31.25-31.25-13.99-31.25-31.25-31.25"/><path fill="#fff" d="M1213.52 89.3501c15.38 0 29.97 3.16 43.38 9.86V155.6c-11.04-9.86-27.61-17.35-44.57-17.35-33.52 0-59.15 27.21-59.15 62.31s25.63 61.91 59.95 61.12c16.96 0 33.12-5.92 43.78-16.56v55.6c-14.2 6.31-28.79 10.65-44.57 10.65-61.92 0-112-49.69-112-110.81s51.27-111.1999 113.19-111.1999zM217.7 194.64c0 65.68-50.87 117.91-164.85 131.01v69.71H0v-195.2c0-69.8 49.3-110.8099 111.22-110.8099S217.7 135.49 217.7 194.64m-52.85 1.97c0-32.34-24.45-56.78-55.61-56.78s-56.4 22.08-56.4 60.33v75.28c42.33-1.2 112-24.91 112-78.83zm536.36 3.94c0 61.12 49.69 110.81 112 110.81 15.38 0 30.37-4.34 44.18-10.65v-55.6c-10.25 10.25-26.82 16.56-43.38 16.56-34.31.79-59.95-26.42-59.95-61.12s25.63-62.31 59.15-62.31c16.96 0 33.13 7.49 44.18 17.35V99.2001c-13.41-6.7-28-9.86-42.6-9.86-62.71 0-113.58 49.6899-113.58 111.1999zm780.88 104.5-91.82-108.36 81.57-101.0399h-64.68l-77.3 102.5399V5.36011h-53.24V305.06h53.24V201.17l87.95 103.89h64.28zm-795.07-94.64v94.64h-52.85v-22.7c-16.27 18.33-39.33 29.01-65.86 29.01-56.4 0-102.54-49.69-102.54-110.02S513.09 89.3501 577.38 89.3501c71.78 0 109.64 52.8399 109.64 121.0599m-53.24-9.86c0-35.1-24.45-62.31-57.58-62.31s-58.77 27.21-58.77 62.31 25.64 61.91 58.77 61.91 57.58-26.82 57.58-61.91m453.93 0c0 61.12-50.48 110.81-112.4 110.81s-112-49.69-112-110.81 50.87-111.1999 112-111.1999 112.4 50.0799 112.4 111.1999m-52.85 0c0-35.49-26.03-62.31-59.55-62.31s-59.15 27.21-59.15 62.31 25.63 61.91 59.15 61.91 59.55-27.21 59.55-61.91m-583.68-2.36c0 5.13-.4 13.8-1.18 22.87H285.4c7.99 25.74 31.55 41.8 64.83 41.8 25.24 0 49.3-9.07 69.8-26.42v54.81c-17.75 12.62-42.59 20.11-71.38 20.11-68.62 0-117.53-45.35-117.53-109.23s50.09-112.7799 112-112.7799S451.58 137.85 451.18 198.19M285.8 178.87h110.56c-6.82-24.48-26.88-41.01-53.63-41.01s-48.96 16.56-56.92 41.01z"/></g><defs><linearGradient id="b" x1="1575.74" x2="1575.74" y1="-.83" y2="400.19" gradientUnits="userSpaceOnUse"><stop stop-color="#ffdc23"/><stop offset=".16" stop-color="#ff8700"/><stop offset=".21" stop-color="#ff5401"/><stop offset=".37" stop-color="#d9005a"/><stop offset=".42" stop-color="#c814c8"/><stop offset=".58" stop-color="#8250ff"/><stop offset=".63" stop-color="#6e64ff"/><stop offset=".79" stop-color="#00c8ff"/><stop offset=".84" stop-color="#0c9"/><stop offset=".99" stop-color="#00a637"/></linearGradient><clipPath id="a"><path fill="#000" d="M0 0h1606.99v400H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB