Add Browse By Studio and Suggested Jellyfin Clients sections to KefinTweaks Home Screen

This commit is contained in:
HighImKevin
2026-09-16 07:27:56 -04:00
parent 1853fd2e4f
commit b00a7d5231
3 changed files with 348 additions and 146 deletions
+317 -145
View File
@@ -628,6 +628,130 @@
return false;
}
/**
* Catalog for Browse by Studio custom section.
* `name` must match Jellyfin Studio.Name exactly. Paramount uses "Paramount Pictures" (common JF name).
*/
const BROWSE_BY_STUDIO_CATALOG = [
{ name: 'AMC', file: 'AMC.svg' },
{ name: 'Apple TV', file: 'Apple_TV.svg' },
{ name: 'Columbia Pictures', file: 'Columbia_Pictures.svg' },
{ name: 'Disney+', file: 'Disney+.svg' },
{ name: 'Dreamworks Animation', file: 'DreamWorks_Animation.svg' },
{ name: 'FX', file: 'FX.svg' },
{ name: 'HBO', file: 'HBO.svg' },
{ name: 'Lucasfilm', 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' },
{ name: 'Pixar', file: 'Pixar.svg' },
{ name: 'Showtime', file: 'Showtime.svg' },
{ name: 'Universal Pictures', file: 'Universal_Pictures.svg' },
{ name: 'Walt Disney Pictures', file: 'Walt_Disney_Pictures.svg' },
{ name: 'Warner Bros.', file: 'Warner_Bros..svg' },
{ name: 'HBO Max', file: 'HBO_Max.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';
let browseStudiosFetchPromise = null;
function isBrowseByStudioCatalogFullyResolved(existingItems) {
const resolvedNames = new Set(
(existingItems || [])
.filter((item) => item?.Id && item?.Name)
.map((item) => item.Name)
);
return BROWSE_BY_STUDIO_CATALOG.every((entry) => resolvedNames.has(entry.name));
}
async function fetchAllStudiosForBrowseSection() {
if (browseStudiosFetchPromise) {
return browseStudiosFetchPromise;
}
browseStudiosFetchPromise = (async () => {
const userId = window.ApiClient?.getCurrentUserId?.();
const serverAddress = window.ApiClient?.serverAddress?.();
if (!userId || !serverAddress || !window.apiHelper?.getData) {
return [];
}
const url = `${serverAddress}/Studios?UserId=${encodeURIComponent(userId)}`;
const data = await window.apiHelper.getData(url, true, 24 * 60 * 60 * 1000);
if (Array.isArray(data?.Items)) return data.Items;
if (Array.isArray(data)) return data;
return [];
})().finally(() => {
browseStudiosFetchPromise = null;
});
return browseStudiosFetchPromise;
}
function buildBrowseByStudioItem(catalogEntry, studio) {
return {
Name: catalogEntry.name,
Type: 'Folder',
Id: studio.Id,
imageUrl: `\${kefinTweaksRoot}pages/images/studios/${catalogEntry.file}`,
cardUrl: `#/details?id=${studio.Id}&serverId=\${serverId}`
};
}
/**
* Build browse-by-studio items from exact Name matches.
* Skips catalog entries already present in existingItems with an Id.
* @param {Array} studios
* @param {Array} [existingItems]
* @returns {Array}
*/
function buildBrowseByStudioItems(studios, existingItems = []) {
const byName = new Map();
(studios || []).forEach((studio) => {
if (studio?.Name != null && studio?.Id) {
byName.set(studio.Name, studio);
}
});
const resolvedNames = new Set(
(existingItems || [])
.filter((item) => item?.Id && item?.Name)
.map((item) => item.Name)
);
const items = [];
BROWSE_BY_STUDIO_CATALOG.forEach((entry) => {
if (resolvedNames.has(entry.name)) return;
const studio = byName.get(entry.name);
if (!studio) return;
items.push(buildBrowseByStudioItem(entry, studio));
});
return items;
}
function createBrowseByStudioSection(items) {
return {
id: BROWSE_BY_STUDIO_SECTION_ID,
name: 'Browse by Studio',
enabled: true,
order: 11,
cardFormat: 'Logo',
ttl: 604800000,
queries: [],
renderMode: 'Normal',
items: items || [],
type: 'home',
userConfigurable: true,
sortBy: 'Random',
cardTitleVisibility: 'hidden',
hideName: true,
useGaplessCards: false
};
}
/**
* Verify and sync library-based section groups (Recently Added, Popular Genres, etc.) with actual Jellyfin libraries
* @param {Object} config - Current configuration object
@@ -646,154 +770,201 @@
}
const libraries = await window.dataHelper.getLibraries();
if (!libraries || libraries.length === 0) {
LOG('No libraries found, skipping library sections verification');
return { config, hasChanges: false };
}
// Filter out boxsets and playlists; all templates apply to this list
const filteredLibraries = libraries.filter(l => l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists');
const filteredLibraries = (libraries || []).filter(l => l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists');
const currentLibraryIds = new Set(filteredLibraries.map(l => l.Id));
const libraryTemplates = [
{
idPrefix: 'recently-added-',
groupId: 'home-recently-added',
groupName: 'Recently Added',
sectionTemplate: {
enabled: false,
order: 61,
cardFormat: 'Poster',
buildSection: function (library) {
let viewMoreUrl = null;
if (library.CollectionType === 'movies') {
viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`;
} else if (library.CollectionType === 'tvshows') {
viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`;
}
return {
id: `recently-added-${library.Id}`,
name: `Recently Added ${library.Name}`,
viewMoreUrl: viewMoreUrl,
jellyfinId: 'latestmedia',
userConfigurable: true,
queries: [{
path: '/Items/Latest',
queryOptions: {
ParentId: library.Id,
Fields: 'PrimaryImageAspectRatio,Path',
Limit: 16,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
}
}]
};
}
}
},
{
idPrefix: 'popular-genres-',
groupId: 'home-popular-genres',
groupName: 'Popular Genres',
sectionTemplate: {
enabled: false,
order: 81,
cardFormat: 'Thumb',
ttl: 604800000,
buildSection: function (library) {
const ct = (library.CollectionType || '').toLowerCase();
let includeItemTypes = ['Movie', 'Series', 'Video', 'MusicArtist', 'Book', 'AudioBook'];
if (ct === 'music') includeItemTypes = ['MusicArtist'];
else if (ct === 'tvshows') includeItemTypes = ['Series'];
else if (ct === 'movies') includeItemTypes = ['Movie'];
else if (ct === 'books') includeItemTypes = ['Book', 'AudioBook'];
else if (ct === 'homevideos') includeItemTypes = ['Video'];
return {
id: `popular-genres-${library.Id}`,
name: `Popular ${library.Name} Genres`,
userConfigurable: true,
queries: [{
path: '/Genres',
ParentId: library.Id,
queryOptions: {
ParentId: library.Id,
IncludeItemTypes: includeItemTypes,
SortBy: 'ChildCount',
SortOrder: 'Descending',
Limit: 20
}
}]
};
}
}
}
];
if (!libraries || libraries.length === 0) {
LOG('No libraries found; syncing Browse by Studio only');
}
const groups = config.HOME_SECTION_GROUPS || [];
let updatedGroups = groups.map(g => ({ ...g, sections: Array.isArray(g.sections) ? [...g.sections] : [] }));
let hasChanges = false;
for (const template of libraryTemplates) {
let group = updatedGroups.find(g => g.id === template.groupId || g.name === template.groupName);
if (!group) {
group = {
id: template.groupId,
name: template.groupName,
sections: []
};
updatedGroups.push(group);
hasChanges = true;
} else if (!group.id) {
group.id = template.groupId;
hasChanges = true;
}
const groupSections = group.sections || [];
const templateSectionIds = groupSections.filter(s => s.id && s.id.startsWith(template.idPrefix)).map(s => s.id);
const existingLibraryIdsForTemplate = new Set(templateSectionIds.map(id => id.replace(template.idPrefix, '')));
const sectionsToRemove = groupSections.filter(s => {
if (!s.id || !s.id.startsWith(template.idPrefix)) return false;
const libId = s.id.replace(template.idPrefix, '');
return !currentLibraryIds.has(libId);
});
const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id));
const librariesToAdd = filteredLibraries.filter(l => !existingLibraryIdsForTemplate.has(l.Id));
if (sectionsToRemoveIds.size > 0 || librariesToAdd.length > 0) hasChanges = true;
group.sections = groupSections.filter(s => !sectionsToRemoveIds.has(s.id));
librariesToAdd.forEach(library => {
const base = template.sectionTemplate.buildSection(library);
const newSection = {
...template.sectionTemplate,
...base,
id: base.id,
name: base.name,
queries: base.queries
};
delete newSection.buildSection;
group.sections.push(newSection);
});
}
// 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;
for (const template of libraryTemplates) {
if (s.id.startsWith(template.idPrefix)) {
const libId = s.id.replace(template.idPrefix, '');
return currentLibraryIds.has(libId);
if (filteredLibraries.length > 0) {
const libraryTemplates = [
{
idPrefix: 'recently-added-',
groupId: 'home-recently-added',
groupName: 'Recently Added',
sectionTemplate: {
enabled: false,
order: 61,
cardFormat: 'Poster',
buildSection: function (library) {
let viewMoreUrl = null;
if (library.CollectionType === 'movies') {
viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`;
} else if (library.CollectionType === 'tvshows') {
viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`;
}
return {
id: `recently-added-${library.Id}`,
name: `Recently Added ${library.Name}`,
viewMoreUrl: viewMoreUrl,
jellyfinId: 'latestmedia',
userConfigurable: true,
queries: [{
path: '/Items/Latest',
queryOptions: {
ParentId: library.Id,
Fields: 'PrimaryImageAspectRatio,Path',
Limit: 16,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
}
}]
};
}
}
},
{
idPrefix: 'popular-genres-',
groupId: 'home-popular-genres',
groupName: 'Popular Genres',
sectionTemplate: {
enabled: false,
order: 81,
cardFormat: 'Thumb',
ttl: 604800000,
buildSection: function (library) {
const ct = (library.CollectionType || '').toLowerCase();
let includeItemTypes = ['Movie', 'Series', 'Video', 'MusicArtist', 'Book', 'AudioBook'];
if (ct === 'music') includeItemTypes = ['MusicArtist'];
else if (ct === 'tvshows') includeItemTypes = ['Series'];
else if (ct === 'movies') includeItemTypes = ['Movie'];
else if (ct === 'books') includeItemTypes = ['Book', 'AudioBook'];
else if (ct === 'homevideos') includeItemTypes = ['Video'];
return {
id: `popular-genres-${library.Id}`,
name: `Popular ${library.Name} Genres`,
userConfigurable: true,
queries: [{
path: '/Genres',
ParentId: library.Id,
queryOptions: {
ParentId: library.Id,
IncludeItemTypes: includeItemTypes,
SortBy: 'ChildCount',
SortOrder: 'Descending',
Limit: 20
}
}]
};
}
}
}
return true;
});
if (group.sections.length !== beforeCount) {
];
for (const template of libraryTemplates) {
let group = updatedGroups.find(g => g.id === template.groupId || g.name === template.groupName);
if (!group) {
group = {
id: template.groupId,
name: template.groupName,
sections: []
};
updatedGroups.push(group);
hasChanges = true;
} else if (!group.id) {
group.id = template.groupId;
hasChanges = true;
}
const groupSections = group.sections || [];
const templateSectionIds = groupSections.filter(s => s.id && s.id.startsWith(template.idPrefix)).map(s => s.id);
const existingLibraryIdsForTemplate = new Set(templateSectionIds.map(id => id.replace(template.idPrefix, '')));
const sectionsToRemove = groupSections.filter(s => {
if (!s.id || !s.id.startsWith(template.idPrefix)) return false;
const libId = s.id.replace(template.idPrefix, '');
return !currentLibraryIds.has(libId);
});
const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id));
const librariesToAdd = filteredLibraries.filter(l => !existingLibraryIdsForTemplate.has(l.Id));
if (sectionsToRemoveIds.size > 0 || librariesToAdd.length > 0) hasChanges = true;
group.sections = groupSections.filter(s => !sectionsToRemoveIds.has(s.id));
librariesToAdd.forEach(library => {
const base = template.sectionTemplate.buildSection(library);
const newSection = {
...template.sectionTemplate,
...base,
id: base.id,
name: base.name,
queries: base.queries
};
delete newSection.buildSection;
group.sections.push(newSection);
});
}
// 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;
for (const template of libraryTemplates) {
if (s.id.startsWith(template.idPrefix)) {
const libId = s.id.replace(template.idPrefix, '');
return currentLibraryIds.has(libId);
}
}
return true;
});
if (group.sections.length !== beforeCount) {
hasChanges = true;
}
}
}
// Browse by Studio: create once, then backfill unresolved catalog entries
try {
let studiosGroup = updatedGroups.find(
(g) => g.id === BROWSE_BY_STUDIO_GROUP_ID || g.name === BROWSE_BY_STUDIO_GROUP_NAME
);
if (!studiosGroup) {
studiosGroup = {
id: BROWSE_BY_STUDIO_GROUP_ID,
name: BROWSE_BY_STUDIO_GROUP_NAME,
sections: []
};
updatedGroups.push(studiosGroup);
hasChanges = true;
} else if (!studiosGroup.id) {
studiosGroup.id = BROWSE_BY_STUDIO_GROUP_ID;
hasChanges = true;
}
if (!Array.isArray(studiosGroup.sections)) {
studiosGroup.sections = [];
}
const browseSection = studiosGroup.sections.find((s) => s?.id === BROWSE_BY_STUDIO_SECTION_ID);
const existingItems = Array.isArray(browseSection?.items) ? browseSection.items : [];
if (browseSection && isBrowseByStudioCatalogFullyResolved(existingItems)) {
LOG('Browse by Studio catalog fully resolved; skipping /Studios fetch');
} else {
const studios = await fetchAllStudiosForBrowseSection();
if (!browseSection) {
const items = buildBrowseByStudioItems(studios, []);
studiosGroup.sections.push(createBrowseByStudioSection(items));
hasChanges = true;
LOG(`Created browse-by-studio with ${items.length} matched studios`);
} else {
const toAppend = buildBrowseByStudioItems(studios, existingItems);
if (toAppend.length > 0) {
browseSection.items = existingItems.concat(toAppend);
hasChanges = true;
LOG(`Backfilled ${toAppend.length} browse-by-studio studios`);
}
}
}
} catch (browseErr) {
WARN('Browse by Studio sync skipped:', browseErr);
}
if (!hasChanges) {
@@ -933,20 +1104,21 @@
}
}
let startupSyncDone = false;
async function runStartupDefaultSectionSync() {
if (startupSyncDone) return;
if (window.__kefinHomeStartupDefaultSectionSync) return;
window.__kefinHomeStartupDefaultSectionSync = true;
if (window.userHelper?.waitForLogin) {
const loggedIn = await window.userHelper.waitForLogin();
if (!loggedIn) return;
if (!loggedIn) {
window.__kefinHomeStartupDefaultSectionSync = false;
return;
}
} else if (!window.ApiClient?._loggedIn) {
window.__kefinHomeStartupDefaultSectionSync = false;
return;
}
startupSyncDone = true;
try {
if (window.migrateHomeScreenConfig) {
await window.migrateHomeScreenConfig();
@@ -2048,7 +2220,7 @@
const userHomeScreen = currentConfig.USER_HOME_SCREEN_SETTINGS || {};
const libraryCache = currentConfig.LIBRARY_CACHE || {};
const D = GENERAL_TOGGLE_DESCRIPTIONS;
const maxPeopleCount = homeSettings.maxPeopleCount ?? 500;
const maxPeopleCount = homeSettings.maxPeopleCount ?? 100;
const maxPeopleOptions = MAX_PEOPLE_COUNT_OPTIONS.map((n) => ({ value: String(n), label: String(n) }));
const settingsGroupStyle = 'margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;';
return `
@@ -2270,7 +2442,7 @@
minPeopleAppearancesMovies: parseInt(root.querySelector('#home-minPeopleAppearancesMovies')?.value || '10', 10),
minPeopleAppearancesSeries: parseInt(root.querySelector('#home-minPeopleAppearancesSeries')?.value || '10', 10),
minPeopleAppearancesEpisodes: parseInt(root.querySelector('#home-minPeopleAppearancesEpisodes')?.value || '10', 10),
maxPeopleCount: parseInt(root.querySelector('#home-maxPeopleCount')?.value || '500', 10),
maxPeopleCount: parseInt(root.querySelector('#home-maxPeopleCount')?.value || '100', 10),
loadPeopleEpisodeData: root.querySelector('#home-loadPeopleEpisodeData')?.checked === true
};
syncEnsureThumbsOnPopularStudios(currentConfig);
+4
View File
@@ -346,3 +346,7 @@ body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
opacity: 0;
visibility: hidden;
}
[data-section-id="browse-by-studio"] .card {
width: 8vw;
}
+27 -1
View File
@@ -977,6 +977,32 @@
}
]
},
// Misc
{
id: 'home-misc',
name: 'Misc',
sections: [
{
id: 'suggested-jellyfin-clients',
name: 'Suggested Jellyfin Clients',
enabled: false,
order: 50,
cardFormat: 'Thumb',
queries: [],
renderMode: 'Normal',
type: 'home',
userConfigurable: true,
ttl: 3600000,
items: [
{ Name: 'AFinity', Type: 'Folder', cardUrl: 'https://afinity.vercel.app/', imageUrl: '${kefinTweaksRoot}pages/images/clients/afinity.png' },
{ Name: 'Moonfin', Type: 'Folder', cardUrl: 'https://moonfin.io/', imageUrl: '${kefinTweaksRoot}pages/images/clients/moonfin.png' },
{ Name: 'Neptune', Type: 'Folder', cardUrl: 'https://neptuneplayer.com/', imageUrl: '${kefinTweaksRoot}pages/images/clients/neptune.png' },
{ Name: 'Pelagica', Type: 'Folder', cardUrl: 'https://pelagica.app/', imageUrl: '${kefinTweaksRoot}pages/images/clients/pelagica.png' },
{ Name: 'Streamyfin', Type: 'Folder', cardUrl: 'https://streamyfin.app/', imageUrl: '${kefinTweaksRoot}pages/images/clients/streamyfin.png' }
]
}
]
},
];
// Seasonal Sections
@@ -2204,7 +2230,7 @@
minPeopleAppearancesMovies: 10,
minPeopleAppearancesSeries: 10,
minPeopleAppearancesEpisodes: 10,
maxPeopleCount: 1000,
maxPeopleCount: 100,
loadPeopleEpisodeData: false,
}