diff --git a/packages/app/src/App/App.js b/packages/app/src/App/App.js index eab35e3d..6d54c1ff 100644 --- a/packages/app/src/App/App.js +++ b/packages/app/src/App/App.js @@ -123,7 +123,7 @@ const PANELS = { const AppContent = (props) => { const {isAuthenticated, isLoading, logout, serverUrl, serverName, api, user, hasMultipleServers, accessToken, connectionState, revalidateSession} = useAuth(); - const {settings, activeTheme} = useSettings(); + const {settings, activeTheme, syncFromServer} = useSettings(); const {streamNotification, dismissStreamNotification, adminMessage, dismissAdminMessage} = useSeerr(); const themeMusic = useThemeMusic(); const {openDialog: openSyncPlay, closeDialog: closeSyncPlay, isDialogOpen: syncPlayDialogOpen, playQueueItem, clearPlayQueueItem, isInGroup: isSyncPlayInGroup, setNewQueue: syncPlaySetNewQueue, displayMessage: syncPlayMessage, clearDisplayMessage: clearSyncPlayMessage} = useSyncPlay(); @@ -219,6 +219,14 @@ const AppContent = (props) => { setPinCodeError(''); }, [isAuthenticated, settings.pinCodeProtection, user?.Id]); + useEffect(() => { + if (isAuthenticated && serverUrl && accessToken && settings.useMoonfinPlugin) { + syncFromServer(serverUrl, accessToken).catch((err) => { + console.warn('[App] Initial settings sync failed:', err.message); + }); + } + }, [isAuthenticated, serverUrl, accessToken, settings.useMoonfinPlugin, syncFromServer]); + useEffect(() => { if (!isPinGateActive) return; const timer = setTimeout(() => { diff --git a/packages/app/src/components/MediaCard/MediaCard.js b/packages/app/src/components/MediaCard/MediaCard.js index b1aea01b..29597b34 100644 --- a/packages/app/src/components/MediaCard/MediaCard.js +++ b/packages/app/src/components/MediaCard/MediaCard.js @@ -41,6 +41,20 @@ const MediaCard = ({item, serverUrl, cardType = 'portrait', onSelect, onFocusIte const imageType = settings.homeRowsImageType || 'poster'; const providerIds = item.ProviderIds || {}; + if (item.Type === 'Genre' && item._representative) { + const rep = item._representative; + const repServerUrl = itemServerUrl; + if (imageType === 'thumb' && rep.ImageTags?.Thumb) { + return getImageUrl(repServerUrl, rep.Id, 'Thumb', {maxWidth: 400, quality: 80}); + } + if (imageType === 'backdrop' && rep.BackdropImageTags?.length > 0) { + return getImageUrl(repServerUrl, rep.Id, 'Backdrop', {maxWidth: 400, quality: 80}); + } + if (rep.ImageTags?.Primary) { + return getImageUrl(repServerUrl, rep.Id, 'Primary', {maxHeight: 300, quality: 80}); + } + } + if (isLandscape && item.Type === 'Episode') { if (settings.useSeriesThumbnails && item.SeriesId && item.SeriesPrimaryImageTag) { return getImageUrl(itemServerUrl, item.SeriesId, 'Primary', {maxHeight: 300, quality: 80}); @@ -98,7 +112,7 @@ const MediaCard = ({item, serverUrl, cardType = 'portrait', onSelect, onFocusIte } return null; - }, [isLandscape, item.Type, item.ImageTags?.Primary, item.ImageTags?.Thumb, item.ImageTags?.Logo, item.Id, item.ParentThumbItemId, item.ParentBackdropItemId, item.BackdropImageTags, item.ParentLogoItemId, item.AlbumId, item.AlbumPrimaryImageTag, item.SeriesId, item.SeriesPrimaryImageTag, item.ProviderIds, item._externalPosterUrl, itemServerUrl, settings.homeRowsImageType, settings.useSeriesThumbnails]); + }, [isLandscape, item.Type, item.ImageTags?.Primary, item.ImageTags?.Thumb, item.ImageTags?.Logo, item.Id, item.ParentThumbItemId, item.ParentBackdropItemId, item.BackdropImageTags, item.ParentLogoItemId, item.AlbumId, item.AlbumPrimaryImageTag, item.SeriesId, item.SeriesPrimaryImageTag, item.ProviderIds, item._externalPosterUrl, itemServerUrl, settings.homeRowsImageType, settings.useSeriesThumbnails, item._representative]); const handleClick = useCallback(() => { onSelect?.(item); @@ -155,15 +169,23 @@ const MediaCard = ({item, serverUrl, cardType = 'portrait', onSelect, onFocusIte
{imageUrl ? ( - {item.Name} + <> + {item.Name} + {(item?.Type === 'Genre' || item?.Type === 'MusicGenre') && ( + <> +
+
{item.Name?.toUpperCase()}
+ + )} + ) : (
{item.Name?.[0]}
)} @@ -174,7 +196,7 @@ const MediaCard = ({item, serverUrl, cardType = 'portrait', onSelect, onFocusIte
)} - {showServerBadge && item._serverName && ( + {(showServerBadge || item._external) && item._serverName && (
{item._serverName}
)} @@ -203,6 +225,11 @@ const MediaCard = ({item, serverUrl, cardType = 'portrait', onSelect, onFocusIte
{displayTitle}
{musicInfo}
+ ) : item.Subtitle ? ( + <> +
{displayTitle}
+
{item.Subtitle}
+ ) : (
{displayTitle}
)} diff --git a/packages/app/src/components/MediaCard/MediaCard.module.less b/packages/app/src/components/MediaCard/MediaCard.module.less index 3a90f54d..286d2004 100644 --- a/packages/app/src/components/MediaCard/MediaCard.module.less +++ b/packages/app/src/components/MediaCard/MediaCard.module.less @@ -226,4 +226,36 @@ .watchedBadge { @import '../../styles/mixins.less'; .watched-badge(); +} + +.genreOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.45); + z-index: 1; + border-radius: var(--theme-card-radius, 20px); +} + +.genreTitle { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + z-index: 2; + color: rgba(255, 255, 255, 0.9); + font-size: 26px; + font-weight: 700; + letter-spacing: 0.15em; + text-align: center; + text-transform: uppercase; + pointer-events: none; + word-break: break-word; } \ No newline at end of file diff --git a/packages/app/src/components/MediaCard/ModernMediaCard.js b/packages/app/src/components/MediaCard/ModernMediaCard.js index 777f64af..c5be29f8 100644 --- a/packages/app/src/components/MediaCard/ModernMediaCard.js +++ b/packages/app/src/components/MediaCard/ModernMediaCard.js @@ -44,6 +44,7 @@ const getGenreNames = (item) => { const getMetadataLine = (item) => { if (!item) return ''; const parts = []; + if (item.UserRating) parts.push(item.UserRating); if (item.ProductionYear) parts.push(String(item.ProductionYear)); const genres = getGenreNames(item).slice(0, 3); if (genres.length) parts.push(genres.join(' • ')); @@ -113,7 +114,24 @@ const ModernMediaCard = ({ if (episodeImage) return episodeImage; } - if (item.Type === 'Movie' || item.Type === 'Series') { + if (item.Type === 'Genre' && item._representative) { + const rep = item._representative; + const repServerUrl = itemServerUrl; + if (isFocused) { + if (rep.ImageTags?.Thumb) { + return getImageUrl(repServerUrl, rep.Id, 'Thumb', {maxWidth: 600, quality: 80}); + } + if (rep.BackdropImageTags?.length > 0) { + return getImageUrl(repServerUrl, rep.Id, 'Backdrop', {maxWidth: 600, quality: 80}); + } + } + if (rep.ImageTags?.Primary) { + return getImageUrl(repServerUrl, rep.Id, 'Primary', {maxHeight: 360, quality: 80}); + } + } + + if (item.Type === 'Movie' || item.Type === 'Series' || item.Type === 'BoxSet') { + const imageType = settings.homeRowsImageType || 'poster'; if (isFocused) { if (item.ImageTags?.Thumb) { return getImageUrl(itemServerUrl, item.Id, 'Thumb', {maxWidth: 600, quality: 80}); @@ -121,12 +139,41 @@ const ModernMediaCard = ({ if (item.BackdropImageTags?.length > 0) { return getImageUrl(itemServerUrl, item.Id, 'Backdrop', {maxWidth: 600, quality: 80}); } + if (item._externalBackdropUrl) { + return toAbsoluteImageUrl(item._externalBackdropUrl, itemServerUrl); + } } + + if (imageType === 'thumb' && item.ImageTags?.Thumb) { + return getImageUrl(itemServerUrl, item.Id, 'Thumb', {maxWidth: 600, quality: 80}); + } + if (imageType === 'backdrop' && item.BackdropImageTags?.length > 0) { + return getImageUrl(itemServerUrl, item.Id, 'Backdrop', {maxWidth: 600, quality: 80}); + } + if (item.ImageTags?.Primary) { return getImageUrl(itemServerUrl, item.Id, 'Primary', {maxHeight: 360, quality: 80}); } } + if (item.Type === 'CollectionFolder' || item.isLibraryTile) { + if (isFocused) { + if (item.ImageTags?.Thumb) { + return getImageUrl(itemServerUrl, item.Id, 'Thumb', {maxWidth: 600, quality: 80}); + } + if (item.BackdropImageTags?.length > 0) { + return getImageUrl(itemServerUrl, item.Id, 'Backdrop', {maxWidth: 600, quality: 80}); + } + } + + if (item.ImageTags?.Primary) { + return getImageUrl(itemServerUrl, item.Id, 'Primary', {maxHeight: 360, quality: 80}); + } + if (item.ImageTags?.Thumb) { + return getImageUrl(itemServerUrl, item.Id, 'Thumb', {maxWidth: 600, quality: 80}); + } + } + if (item.Type === 'Audio' && item.AlbumId && item.AlbumPrimaryImageTag) { return getImageUrl(itemServerUrl, item.AlbumId, 'Primary', {maxHeight: 360, quality: 80}); } @@ -153,7 +200,7 @@ const ModernMediaCard = ({ return toAbsoluteImageUrl(externalPoster, itemServerUrl); } return null; - }, [item, itemServerUrl, isFocused, settings.useSeriesThumbnails]); + }, [item, itemServerUrl, isFocused, settings.useSeriesThumbnails, settings.homeRowsImageType]); const handleClick = useCallback(() => { onSelect?.(item); @@ -185,8 +232,9 @@ const ModernMediaCard = ({ const overviewText = useMemo(() => { if (!shouldShowOverview) return ''; const rawOverview = typeof item?.Overview === 'string' ? item.Overview.trim() : ''; + if (item?._external && !rawOverview) return ''; return rawOverview || $L('No description available.'); - }, [item?.Overview, shouldShowOverview]); + }, [item?.Overview, shouldShowOverview, item?._external]); const sizeMultiplier = POSTER_SIZE_MULTIPLIERS[settings.homeRowsPosterSize] || 1; const imageHeight = Math.round(360 * sizeMultiplier); @@ -194,7 +242,14 @@ const ModernMediaCard = ({ const cardWidth = isSquareItem ? imageHeight : Math.round((imageHeight * 2) / 3); const expandedWidthFactor = platform === 'tizen' ? 16 / 9 : 1.65; const expandedWidth = Math.max(cardWidth, Math.round(imageHeight * expandedWidthFactor)); - const canRenderExpanded = !isSquareItem && Boolean(metadata || item?.CommunityRating || shouldShowOverview); + const canRenderExpanded = !isSquareItem && ( + Boolean(metadata || item?.CommunityRating || (shouldShowOverview && overviewText)) || + item?.Type === 'Genre' || + item?.Type === 'CollectionFolder' || + item?.isLibraryTile || + item?._external === true || + item?._seerr === true + ); const cardClassName = [ css.card, @@ -222,15 +277,23 @@ const ModernMediaCard = ({ >
{imageUrl ? ( - {item?.Name} + <> + {item?.Name} + {(item?.Type === 'Genre' || item?.Type === 'MusicGenre') && ( + <> +
+
{item?.Name?.toUpperCase()}
+ + )} + ) : (
{item?.Type === 'Person' ? ( @@ -249,7 +312,7 @@ const ModernMediaCard = ({
)} - {showServerBadge && item?._serverName && ( + {(showServerBadge || item?._external) && item?._serverName && (
{item._serverName}
)} @@ -259,7 +322,9 @@ const ModernMediaCard = ({
{displayTitle}
- {episodeLabel &&
{episodeLabel}
} + {(episodeLabel || item?.Subtitle) && ( +
{episodeLabel || item.Subtitle}
+ )} {isFocused && canRenderExpanded && (
diff --git a/packages/app/src/components/MediaCard/ModernMediaCard.module.less b/packages/app/src/components/MediaCard/ModernMediaCard.module.less index 48e43967..a6eb9e0e 100644 --- a/packages/app/src/components/MediaCard/ModernMediaCard.module.less +++ b/packages/app/src/components/MediaCard/ModernMediaCard.module.less @@ -237,3 +237,35 @@ max-width: min(var(--modern-card-expanded-width), calc(100vw - 260px)); min-height: 120px; } + +.genreOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.45); + z-index: 1; + border-radius: var(--theme-card-radius, 20px); +} + +.genreTitle { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + z-index: 2; + color: rgba(255, 255, 255, 0.9); + font-size: 26px; + font-weight: 700; + letter-spacing: 0.15em; + text-align: center; + text-transform: uppercase; + pointer-events: none; + word-break: break-word; +} diff --git a/packages/app/src/components/MediaRow/MediaRow.js b/packages/app/src/components/MediaRow/MediaRow.js index 7c658e35..349ac394 100644 --- a/packages/app/src/components/MediaRow/MediaRow.js +++ b/packages/app/src/components/MediaRow/MediaRow.js @@ -135,7 +135,7 @@ const MediaRow = ({ return ( [l, s]) ); -const TV_TO_SERVER_ROW = { - 'latest-media': 'latestmedia', - 'recently-released': 'recentlyreleased', - 'library-tiles': 'smalllibrarytiles', - 'favoriteMovies': 'favoritemovies', - 'favoriteSeries': 'favoriteseries', - 'favoriteEpisodes': 'favoriteepisodes', - 'favoritePeople': 'favoritepeople', - 'favoriteArtists': 'favoriteartists', - 'favoriteMusicVideos': 'favoritemusicvideos', - 'favoriteAlbums': 'favoritealbums', - 'favoriteSongs': 'favoritesongs', - 'genres': 'genres', - 'imdb-top250-movies': 'imdb_top_250_movies', - 'imdb-top250-tv': 'imdb_top_250_tv_shows', - 'imdb-popular-movies': 'imdb_most_popular_movies', - 'imdb-popular-tv': 'imdb_most_popular_tv_shows', - 'imdb-lowest-rated': 'imdb_lowest_rated_movies', - 'imdb-top-english': 'imdb_top_english_movies' -}; -const SERVER_TO_TV_ROW = { - 'latestmedia': 'latest-media', - 'recentlyreleased': 'recently-released', - 'smalllibrarytiles': 'library-tiles', - 'favoritemovies': 'favoriteMovies', - 'favoriteseries': 'favoriteSeries', - 'favoriteepisodes': 'favoriteEpisodes', - 'favoritepeople': 'favoritePeople', - 'favoriteartists': 'favoriteArtists', - 'favoriteMusicVideos': 'favoriteMusicVideos', - 'favoritemusicvideos': 'favoriteMusicVideos', - 'favoritealbums': 'favoriteAlbums', - 'favoritesongs': 'favoriteSongs', - 'genres': 'genres', - 'imdb_top_250_movies': 'imdb-top250-movies', - 'imdb_top_250_tv_shows': 'imdb-top250-tv', - 'imdb_most_popular_movies': 'imdb-popular-movies', - 'imdb_most_popular_tv_shows': 'imdb-popular-tv', - 'imdb_lowest_rated_movies': 'imdb-lowest-rated', - 'imdb_top_english_movies': 'imdb-top-english' -}; - -export {TV_TO_SERVER_ROW}; - -const mergeHomeRows = (rows) => { - if (!Array.isArray(rows)) return [...DEFAULT_HOME_ROWS]; - const merged = [...rows]; - let added = false; - for (const def of DEFAULT_HOME_ROWS) { - if (!merged.find((row) => row.id === def.id)) { - merged.push({...def, enabled: false, order: merged.length}); - added = true; - } - } - if (!added) return rows; - return merged; +// Synced values are all JSON, so comparing structure is enough to tell a genuinely new +// value from a fresh copy of the one we already have. +const sameSyncedValue = (left, right) => { + if (left === right) return true; + if (typeof left !== typeof right) return false; + if (left === null || right === null) return false; + if (typeof left !== 'object') return false; + return JSON.stringify(left) === JSON.stringify(right); }; const normalizeHomeRowsStyle = (value) => { @@ -341,31 +240,9 @@ const VALUE_CONVERSIONS = { }, mediaBarCollectionIds: { fromServer: normalizeGuidArray - }, - homeRows: { - toServer: rows => { - if (!Array.isArray(rows)) return undefined; - return [...rows] - .sort((a, b) => a.order - b.order) - .filter(r => r.enabled) - .map(r => TV_TO_SERVER_ROW[r.id] || r.id); - }, - fromServer: serverIds => { - if (!Array.isArray(serverIds) || serverIds.length === 0) return undefined; - const rows = []; - serverIds.forEach((sid, i) => { - const tvId = SERVER_TO_TV_ROW[sid] || sid; - const def = DEFAULT_HOME_ROWS.find(r => r.id === tvId); - if (def) rows.push({...def, enabled: true, order: i}); - }); - DEFAULT_HOME_ROWS.forEach(def => { - if (!rows.find(r => r.id === def.id)) { - rows.push({...def, enabled: false, order: rows.length}); - } - }); - return rows; - } } + // homeRows is missing on purpose. The home layout is two server fields that have to + // move together, so it gets resolved whole rather than a key at a time. }; const SYNCABLE_KEYS = [ @@ -431,12 +308,20 @@ const profileToLocal = (serverProfile) => { const localToProfile = (localSettings) => { const profile = {}; for (const key of SYNCABLE_KEYS) { + if (key === 'homeRows') continue; const value = localSettings[key]; if (value === undefined || value === null) continue; const serverKey = LOCAL_TO_SERVER[key] || key; const conv = VALUE_CONVERSIONS[key]; profile[serverKey] = conv?.toServer ? conv.toServer(value) : value; } + // Send both views or neither. homeRowOrder on its own makes the server throw away the + // stored homeSections, whereas sending neither leaves the stored layout alone. That is + // what we want before we have read it and know which sections to preserve. + if (Array.isArray(localSettings.homeRows) && hasSeenServerLayout()) { + profile.homeSections = homeRowsToSections(localSettings.homeRows); + profile.homeRowOrder = homeRowsToRowOrder(localSettings.homeRows); + } return profile; }; @@ -457,6 +342,14 @@ const resolveFromEnvelope = (envelope, adminDefaults) => { } const tmdbKey = tvProfile.tmdbApiKey ?? globalProfile.tmdbApiKey ?? adminProfile.tmdbApiKey; if (tmdbKey !== undefined) resolved.tmdbApiKey = tmdbKey; + + // Same precedence as everything else, except the layout moves as one unit. The first + // profile that has any layout supplies all of it, so admin defaults only reach a user + // with no layout of their own. + const homeRows = homeRowsFromProfile(envelope?.tv) + ?? homeRowsFromProfile(envelope?.global) + ?? homeRowsFromProfile(adminDefaults); + if (homeRows !== undefined) resolved.homeRows = homeRows; return resolved; }; @@ -729,25 +622,54 @@ export function SettingsProvider({children}) { const hasServerValues = resolved.tmdbApiKey !== undefined || SYNCABLE_KEYS.some(key => resolved[key] !== undefined); if (!hasServerValues) return; - setSettings(prev => { - const updated = {...prev}; + const nextValues = {}; for (const key of SYNCABLE_KEYS) { - if (resolved[key] !== undefined) updated[key] = resolved[key]; + const incoming = resolved[key]; + // Hold on to the previous reference when the value hasn't really changed. + // An equal but freshly built array still counts as a new identity, which + // would send Browse off to reload every row on every sync. + nextValues[key] = incoming === undefined || sameSyncedValue(incoming, prev[key]) + ? prev[key] + : incoming; } - if (resolved.tmdbApiKey !== undefined) updated.tmdbApiKey = resolved.tmdbApiKey; - updated.homeRowsStyle = normalizeHomeRowsStyle(updated.homeRowsStyle); - updated.detailScreenStyle = normalizeDetailScreenStyle(updated.detailScreenStyle); - if (updated.customThemeId && !getAvailableThemes()[updated.customThemeId]) { - updated.customThemeId = ''; + const tmdbApiKey = resolved.tmdbApiKey !== undefined ? resolved.tmdbApiKey : prev.tmdbApiKey; + const homeRowsStyle = normalizeHomeRowsStyle(nextValues.homeRowsStyle); + const detailScreenStyle = normalizeDetailScreenStyle(nextValues.detailScreenStyle); + + let customThemeId = nextValues.customThemeId; + if (customThemeId && !getAvailableThemes()[customThemeId]) { + customThemeId = ''; } - if (!isBuiltInThemeId(updated.visualTheme)) { - updated.visualTheme = 'moonfin'; + let visualTheme = nextValues.visualTheme; + if (!isBuiltInThemeId(visualTheme)) { + visualTheme = 'moonfin'; } - saveToStorage('settings', updated); - return updated; - }); + // Unchanged values kept their previous reference, so comparing identity is + // enough here. + const changed = tmdbApiKey !== prev.tmdbApiKey || + homeRowsStyle !== prev.homeRowsStyle || + detailScreenStyle !== prev.detailScreenStyle || + customThemeId !== prev.customThemeId || + visualTheme !== prev.visualTheme || + SYNCABLE_KEYS.some((key) => nextValues[key] !== prev[key]); + + if (changed) { + const updated = { + ...prev, + ...nextValues, + tmdbApiKey, + homeRowsStyle, + detailScreenStyle, + customThemeId, + visualTheme + }; + saveToStorage('settings', updated); + return updated; + } + return prev; + }); } catch (e) { console.warn('[Settings] Server sync failed:', e.message); } diff --git a/packages/app/src/services/externalRowsApi.js b/packages/app/src/services/externalRowsApi.js index a6b81af8..ec89b195 100644 --- a/packages/app/src/services/externalRowsApi.js +++ b/packages/app/src/services/externalRowsApi.js @@ -1,4 +1,6 @@ import {getAuthHeader, getServerUrl} from './jellyfinApi'; +import {fetchWithTimeout} from '../utils/fetchTimeout'; +import {mediaServerQueue} from '../utils/requestQueue'; // Thin client for the Moonfin plugin external home row endpoints. Everything // except the Radarr and Sonarr calendars comes from one generic endpoint, @@ -22,6 +24,7 @@ const normalizeItem = (raw) => { backdropUrl: raw.backdropUrl ?? raw.BackdropUrl ?? null, userRating: raw.userRating ?? raw.UserRating ?? null, rating: raw.rating ?? raw.Rating ?? null, + overview: raw.overview ?? raw.Overview ?? raw.description ?? raw.Description ?? '', providerIds: { Tmdb: providerIds.Tmdb ?? providerIds.tmdb ?? null, Imdb: providerIds.Imdb ?? providerIds.imdb ?? null, @@ -48,7 +51,7 @@ export const fetchCustomRow = async ({source, type, params = {}}, options = {}) // The plugin caches on source:type:sha256(params), so a nocache marker forces // a fresh fetch when the user asks to refresh. - const sentParams = options.forceRefresh ? {...params, _nocache: Date.now()} : params; + const sentParams = options.forceRefresh ? {...params, _nocache: String(Date.now())} : params; try { const url = `${baseUrl}/Moonfin/CustomRows/Items` @@ -59,7 +62,9 @@ export const fetchCustomRow = async ({source, type, params = {}}, options = {}) const fetchOptions = {headers: {'Authorization': getAuthHeader()}}; if (options.signal) fetchOptions.signal = options.signal; - const response = await fetch(url, fetchOptions); + const response = await mediaServerQueue.run( + () => fetchWithTimeout(url, fetchOptions, options.timeoutMs || 10000) + ); if (!response.ok) return cache[key]?.items || []; const data = await response.json(); diff --git a/packages/app/src/services/jellyfinApi.js b/packages/app/src/services/jellyfinApi.js index 77d45fd6..bbffa874 100644 --- a/packages/app/src/services/jellyfinApi.js +++ b/packages/app/src/services/jellyfinApi.js @@ -2,6 +2,7 @@ import packageJson from '../../package.json'; import {buildQueryString} from '../utils/urlCompat'; import {normalizeServerUrl} from '../utils/serverUrl'; import {classifyError} from '../utils/connectionErrors'; +import {mediaServerQueue} from '../utils/requestQueue'; import {platformFetch} from './secureFetch'; import {isTizen} from '../platform'; const APP_VERSION = packageJson.version; @@ -99,7 +100,7 @@ export const HOME_ROW_ITEM_FIELDS = 'PrimaryImageAspectRatio,Overview,Genres,Gen // Routes through the webOS TLS proxy fallback (secureFetch) so Let's-Encrypt // servers work on old TVs whose CA store rejects them; native fetch elsewhere. const fetchWithTimeout = (url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => - platformFetch(url, options, timeoutMs); + mediaServerQueue.run(() => platformFetch(url, options, timeoutMs)); export const getDeviceId = () => deviceId; const request = async (endpoint, options = {}) => { @@ -203,7 +204,15 @@ export const resolveItemsByProviderIds = async (items) => { return items.map((it) => { const key = keyFor(it.ProviderIds); const jf = key ? found[key] : null; - return jf ? {...jf, _resolvedFromExternal: true} : it; + return jf ? { + ...it, + ...jf, + _resolvedFromExternal: true, + _external: it._external, + _externalBackdropUrl: it._externalBackdropUrl, + _externalPosterUrl: it._externalPosterUrl, + UserRating: it.UserRating || jf.UserRating || null + } : it; }); }; diff --git a/packages/app/src/services/secureFetch.js b/packages/app/src/services/secureFetch.js index d609f4b5..fadad99a 100644 --- a/packages/app/src/services/secureFetch.js +++ b/packages/app/src/services/secureFetch.js @@ -117,7 +117,7 @@ const proxyRequest = (url, options, timeoutMs, allowInsecure) => new Promise((re method: options.method || 'GET', headers: options.headers || {}, body: options.body, - timeoutMs: timeoutMs || 15000, + timeoutMs: timeoutMs || 30000, insecure: !!allowInsecure })); }); diff --git a/packages/app/src/services/seerrApi.js b/packages/app/src/services/seerrApi.js index 9886d463..6cf718aa 100644 --- a/packages/app/src/services/seerrApi.js +++ b/packages/app/src/services/seerrApi.js @@ -1,5 +1,6 @@ import {isWebOS, isLegacyTizen} from '../platform'; import {fetchWithTimeout} from '../utils/fetchTimeout'; +import {mediaServerQueue} from '../utils/requestQueue'; let moonfinMode = false; let jellyfinServerUrl = null; @@ -24,11 +25,11 @@ const fetchRequest = async (params) => { const {url, method = 'GET', headers = {}, body, timeout = 15000} = params; try { -const response = await fetchWithTimeout(url, { +const response = await mediaServerQueue.run(() => fetchWithTimeout(url, { method, headers, body: body || undefined -}, timeout); +}, timeout)); const responseBody = await response.text(); @@ -826,6 +827,10 @@ const result = await request(`/request?filter=all&requestedBy=${requestedByUserI return result; }; +export const getRecentlyAdded = async (take = 20) => { +return request(`/media?filter=allavailable&sort=mediaAdded&take=${take}`); +}; + export const REQUEST_STATUS = { PENDING: 1, APPROVED: 2, @@ -1033,6 +1038,7 @@ approveRequest, declineRequest, retryRequest, getMyRequests, +getRecentlyAdded, REQUEST_STATUS, getRequestStatusText, requestMovie, diff --git a/packages/app/src/services/themeStoreApi.js b/packages/app/src/services/themeStoreApi.js index 55413aab..223621d4 100644 --- a/packages/app/src/services/themeStoreApi.js +++ b/packages/app/src/services/themeStoreApi.js @@ -10,7 +10,7 @@ const headers = { // Fetches the Theme Store catalog (index.json). Returns an array of // {id, displayName, description, file}. export const fetchThemeStoreCatalog = async () => { - const response = await fetchWithTimeout(`${BASE_URL}index.json`, {headers}, 15000); + const response = await fetchWithTimeout(`${BASE_URL}index.json`, {headers}, 30000); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = JSON.parse(await response.text()); const themes = Array.isArray(data?.themes) ? data.themes : []; @@ -27,7 +27,7 @@ export const fetchThemeStoreCatalog = async () => { // Fetches a single theme JSON by its catalog file path. Returns the raw object; // caller validates via parseThemeSpec. export const fetchThemeJson = async (file) => { - const response = await fetchWithTimeout(`${BASE_URL}${file}`, {headers}, 10000); + const response = await fetchWithTimeout(`${BASE_URL}${file}`, {headers}, 30000); if (!response.ok) throw new Error(`HTTP ${response.status}`); return JSON.parse(await response.text()); }; diff --git a/packages/app/src/utils/externalHomeRows.js b/packages/app/src/utils/externalHomeRows.js index 9805d7d1..7cd79b60 100644 --- a/packages/app/src/utils/externalHomeRows.js +++ b/packages/app/src/utils/externalHomeRows.js @@ -1,6 +1,7 @@ import $L from '@enact/i18n/$L'; import seerrApi from '../services/seerrApi'; import {fetchCustomRow, constructSourceUrl} from '../services/externalRowsApi'; +import {fetchWithTimeout} from './fetchTimeout'; const HOME_ROW_LIMIT = 20; @@ -36,10 +37,23 @@ const yearOf = (item) => { return Number.isFinite(year) ? year : undefined; }; +const getSourceName = (source, rowId) => { + const src = String(source || rowId || '').toLowerCase(); + if (src.includes('tmdb')) return 'TMDb'; + if (src.includes('imdb')) return 'IMDb'; + if (src.includes('letterboxd')) return 'Letterboxd'; + if (src.includes('trakt')) return 'Trakt'; + if (src.includes('mdblist')) return 'MDBList'; + if (src.includes('radarr')) return 'Radarr'; + if (src.includes('sonarr')) return 'Sonarr'; + if (src.includes('seerr')) return 'Seerr'; + return source || rowId || ''; +}; + // Maps a plugin CustomRows item into a pseudo Jellyfin item. It carries // ProviderIds so it can be resolved to a real library item, and _seerr markers // so unresolved items fall back to the Seerr request detail. -const normalizeExternalItem = (item, rowId) => { +const normalizeExternalItem = (item, rowId, source) => { const mediaType = item.type === 'Series' ? 'tv' : 'movie'; const tmdbId = item.providerIds?.Tmdb || null; const imdbId = item.providerIds?.Imdb || null; @@ -51,9 +65,11 @@ const normalizeExternalItem = (item, rowId) => { ProductionYear: yearOf(item), ProviderIds: item.providerIds, UserRating: item.userRating, + Overview: item.overview || null, _externalPosterUrl: item.posterUrl ? seerrApi.getImageUrl(item.posterUrl, 'w342') : null, _externalBackdropUrl: item.backdropUrl ? seerrApi.getImageUrl(item.backdropUrl, 'w780') : null, _external: true, + _serverName: getSourceName(source, rowId), mediaInfo: {}, _seerr: true, _seerrType: 'item', @@ -96,7 +112,7 @@ export const fetchExternalPresetRow = async (rowId, options = {}) => { const cfg = findPreset(rowId); if (!cfg) return []; const items = await fetchCustomRow({source: cfg.source, type: cfg.type, params: {}}, options); - return items.slice(0, HOME_ROW_LIMIT).map((it) => normalizeExternalItem(it, rowId)); + return items.slice(0, HOME_ROW_LIMIT).map((it) => normalizeExternalItem(it, rowId, cfg.source)); }; // Fetches a user configured custom row. `row` is the stored config @@ -105,8 +121,8 @@ export const fetchCustomHomeRow = async (row, options = {}) => { const items = await fetchCustomRow({source: row.source, type: row.type, params: row.params}, options); const sorted = applySorting(items, row.sortBy, row.sortOrder); return sorted.slice(0, HOME_ROW_LIMIT).map((it) => { - const norm = normalizeExternalItem(it, row.id); - if (!row.showUserRatings) norm.UserRating = null; + const norm = normalizeExternalItem(it, row.id, row.source); + if (row.showUserRatings === false) norm.UserRating = null; return norm; }); }; @@ -168,7 +184,7 @@ const formatCalendarDate = (dateStr) => { const arrGet = async (url) => { try { - const res = await fetch(url); + const res = await fetchWithTimeout(url, {}, 5000); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : []; @@ -202,6 +218,21 @@ const fetchRadarrItems = async (settings) => { if (!upcoming.length) continue; const soonest = upcoming.sort((a, b) => a - b)[0]; const tmdbId = m.tmdbId ? String(m.tmdbId) : null; + + let releaseType = ''; + const soonestTime = soonest.getTime(); + if (m.inCinemas && new Date(m.inCinemas).getTime() === soonestTime) { + releaseType = $L('Cinema: '); + } else if (m.digitalRelease && new Date(m.digitalRelease).getTime() === soonestTime) { + releaseType = $L('Digital: '); + } else if (m.physicalRelease && new Date(m.physicalRelease).getTime() === soonestTime) { + releaseType = $L('Physical: '); + } + + const dateStr = settings.radarrCalendarShowDate ? formatCalendarDate(soonest.toISOString()) : ''; + const subtitle = dateStr ? `${releaseType}${dateStr}` : ''; + const fanart = m.images?.find((i) => i.coverType === 'fanart'); + results.push({ Id: `cal-radarr-${tmdbId || m.id}`, Name: m.title, @@ -209,9 +240,12 @@ const fetchRadarrItems = async (settings) => { ProductionYear: m.year || undefined, ProviderIds: {Tmdb: tmdbId}, _externalPosterUrl: posterFrom(m.images), + _externalBackdropUrl: fanart ? (fanart.remoteUrl || fanart.url || null) : null, _external: true, _calendarDate: soonest.toISOString(), - Subtitle: settings.radarrCalendarShowDate ? formatCalendarDate(soonest.toISOString()) : '', + Subtitle: subtitle, + Overview: m.overview || '', + _serverName: 'Radarr', mediaInfo: {}, _seerr: true, _seerrType: 'item', @@ -257,6 +291,8 @@ const fetchSonarrItems = async (settings) => { ? `S${ep.seasonNumber}E${ep.episodeNumber}` : ''; const dateStr = settings.sonarrCalendarShowDate ? formatCalendarDate(air) : ''; const subtitle = [epInfo, dateStr].filter(Boolean).join(' - '); + const fanart = series.images?.find((i) => i.coverType === 'fanart'); + return { Id: `cal-sonarr-${tmdbId || series.id}`, Name: series.title, @@ -264,9 +300,12 @@ const fetchSonarrItems = async (settings) => { ProductionYear: series.year || undefined, ProviderIds: {Tmdb: tmdbId}, _externalPosterUrl: posterFrom(series.images), + _externalBackdropUrl: fanart ? (fanart.remoteUrl || fanart.url || null) : null, _external: true, _calendarDate: new Date(air).toISOString(), Subtitle: subtitle, + Overview: series.overview || ep.overview || '', + _serverName: 'Sonarr', mediaInfo: {}, _seerr: true, _seerrType: 'item', diff --git a/packages/app/src/utils/homeLayout.js b/packages/app/src/utils/homeLayout.js new file mode 100644 index 00000000..af8bb4cc --- /dev/null +++ b/packages/app/src/utils/homeLayout.js @@ -0,0 +1,212 @@ +/** + * The home row list, and translating it to and from the server. + * + * This lives outside SettingsContext because SettingsContext touches platform storage + * as soon as it is imported, which leaves it impossible to load in a unit test. + * + * The server holds the layout as two views of one value: + * homeSections every row, each with its own enabled flag and order, including rows + * contributed by plugins. The complete picture. + * homeRowOrder the names of the enabled rows only. It can't express "disabled" and + * carries no plugin rows. + * + * Reading prefers homeSections and falls back to homeRowOrder. Writing has to send both: + * the server drops a stored homeSections as soon as it receives a homeRowOrder without + * one, taking every other client's disabled rows and plugin rows with it. + */ + +export const DEFAULT_HOME_ROWS = [ + {id: 'resume', name: 'Continue Watching', enabled: true, order: 0}, + {id: 'nextup', name: 'Next Up', enabled: true, order: 1}, + {id: 'latest-media', name: 'Recently Added Media', enabled: true, order: 2}, + {id: 'collections', name: 'Collections', enabled: false, order: 3}, + {id: 'library-tiles', name: 'My Media', enabled: false, order: 4}, + {id: 'favoriteMovies', name: 'Favorite Movies', enabled: false, order: 5}, + {id: 'favoriteSeries', name: 'Favorite Series', enabled: false, order: 6}, + {id: 'favoriteEpisodes', name: 'Favorite Episodes', enabled: false, order: 7}, + {id: 'favoritePeople', name: 'Favorite People', enabled: false, order: 8}, + {id: 'favoriteArtists', name: 'Favorite Artists', enabled: false, order: 9}, + {id: 'favoriteMusicVideos', name: 'Favorite Music Videos', enabled: false, order: 10}, + {id: 'favoriteAlbums', name: 'Favorite Albums', enabled: false, order: 11}, + {id: 'favoriteSongs', name: 'Favorite Songs', enabled: false, order: 12}, + {id: 'genres', name: 'Genres', enabled: false, order: 13}, + {id: 'recently-released', name: 'Recently Released', enabled: false, order: 14}, + {id: 'imdb-top250-movies', name: 'IMDb Top 250 Movies', enabled: false, order: 15}, + {id: 'imdb-top250-tv', name: 'IMDb Top 250 TV Shows', enabled: false, order: 16}, + {id: 'imdb-popular-movies', name: 'IMDb Most Popular Movies', enabled: false, order: 17}, + {id: 'imdb-popular-tv', name: 'IMDb Most Popular TV Shows', enabled: false, order: 18}, + {id: 'imdb-lowest-rated', name: 'IMDb Lowest Rated Movies', enabled: false, order: 19}, + {id: 'imdb-top-english', name: 'IMDb Top Rated English Movies', enabled: false, order: 20}, + {id: 'sinceyouwatched1', name: 'Since You Watched Row 1', enabled: false, order: 21}, + {id: 'sinceyouwatched2', name: 'Since You Watched Row 2', enabled: false, order: 22}, + {id: 'sinceyouwatched3', name: 'Since You Watched Row 3', enabled: false, order: 23}, + {id: 'sinceyouwatched4', name: 'Since You Watched Row 4', enabled: false, order: 24}, + {id: 'sinceyouwatched5', name: 'Since You Watched Row 5', enabled: false, order: 25}, + {id: 'rewatch', name: 'Rewatch', enabled: false, order: 26}, + {id: 'playlists', name: 'Playlists', enabled: false, order: 27}, + {id: 'audioartists', name: 'Music Artists', enabled: false, order: 28}, + {id: 'audioalbums', name: 'Music Albums', enabled: false, order: 29}, + {id: 'audioplaylists', name: 'Music Playlists', enabled: false, order: 30}, + {id: 'resumeaudio', name: 'Continue Listening', enabled: false, order: 31}, + {id: 'activerecordings', name: 'Recordings', enabled: false, order: 32}, + {id: 'livetv', name: 'Live TV', enabled: false, order: 33}, + {id: 'seerr_recent_requests', name: 'Recent Requests', enabled: false, order: 34}, + {id: 'seerr_recently_added', name: 'Recently Added', enabled: false, order: 35}, + {id: 'seerr_trending', name: 'Trending Now', enabled: false, order: 36}, + {id: 'seerr_popular_movies', name: 'Popular Movies', enabled: false, order: 37}, + {id: 'seerr_popular_series', name: 'Popular TV Shows', enabled: false, order: 38}, + {id: 'seerr_upcoming_movies', name: 'Upcoming Movies', enabled: false, order: 39}, + {id: 'seerr_upcoming_series', name: 'Upcoming TV Shows', enabled: false, order: 40}, + {id: 'seerr_movie_genres', name: 'Browse Movies by Genre', enabled: false, order: 41}, + {id: 'seerr_series_genres', name: 'Browse TV by Genre', enabled: false, order: 42}, + {id: 'seerr_studios', name: 'Browse by Studio', enabled: false, order: 43}, + {id: 'seerr_networks', name: 'Browse by Network', enabled: false, order: 44}, + {id: 'tmdb_popular_movies', name: 'TMDB Popular Movies', enabled: false, order: 45}, + {id: 'tmdb_top_rated_movies', name: 'TMDB Top Rated Movies', enabled: false, order: 46}, + {id: 'tmdb_now_playing_movies', name: 'TMDB Now Playing Movies', enabled: false, order: 47}, + {id: 'tmdb_upcoming_movies', name: 'TMDB Upcoming Movies', enabled: false, order: 48}, + {id: 'tmdb_popular_tv', name: 'TMDB Popular TV', enabled: false, order: 49}, + {id: 'tmdb_top_rated_tv', name: 'TMDB Top Rated TV', enabled: false, order: 50}, + {id: 'tmdb_airing_today_tv', name: 'TMDB Airing Today TV', enabled: false, order: 51}, + {id: 'tmdb_on_the_air_tv', name: 'TMDB On The Air TV', enabled: false, order: 52}, + {id: 'tmdb_trending_movie_daily', name: 'TMDB Trending Movies (Daily)', enabled: false, order: 53}, + {id: 'tmdb_trending_movie_weekly', name: 'TMDB Trending Movies (Weekly)', enabled: false, order: 54}, + {id: 'tmdb_trending_tv_daily', name: 'TMDB Trending TV (Daily)', enabled: false, order: 55}, + {id: 'tmdb_trending_tv_weekly', name: 'TMDB Trending TV (Weekly)', enabled: false, order: 56}, + {id: 'tmdb_trending_all_weekly', name: 'TMDB Trending All (Weekly)', enabled: false, order: 57}, + {id: 'radarr_calendar', name: 'Radarr Upcoming', enabled: false, order: 58}, + {id: 'sonarr_calendar', name: 'Sonarr Upcoming', enabled: false, order: 59} +]; + +export const TV_TO_SERVER_ROW = { + 'latest-media': 'latestmedia', + 'recently-released': 'recentlyreleased', + 'library-tiles': 'smalllibrarytiles', + 'favoriteMovies': 'favoritemovies', + 'favoriteSeries': 'favoriteseries', + 'favoriteEpisodes': 'favoriteepisodes', + 'favoritePeople': 'favoritepeople', + 'favoriteArtists': 'favoriteartists', + 'favoriteMusicVideos': 'favoritemusicvideos', + 'favoriteAlbums': 'favoritealbums', + 'favoriteSongs': 'favoritesongs', + 'genres': 'genres', + 'imdb-top250-movies': 'imdb_top_250_movies', + 'imdb-top250-tv': 'imdb_top_250_tv_shows', + 'imdb-popular-movies': 'imdb_most_popular_movies', + 'imdb-popular-tv': 'imdb_most_popular_tv_shows', + 'imdb-lowest-rated': 'imdb_lowest_rated_movies', + 'imdb-top-english': 'imdb_top_english_movies' +}; +export const SERVER_TO_TV_ROW = { + 'latestmedia': 'latest-media', + 'recentlyreleased': 'recently-released', + 'smalllibrarytiles': 'library-tiles', + 'favoritemovies': 'favoriteMovies', + 'favoriteseries': 'favoriteSeries', + 'favoriteepisodes': 'favoriteEpisodes', + 'favoritepeople': 'favoritePeople', + 'favoriteartists': 'favoriteArtists', + 'favoriteMusicVideos': 'favoriteMusicVideos', + 'favoritemusicvideos': 'favoriteMusicVideos', + 'favoritealbums': 'favoriteAlbums', + 'favoritesongs': 'favoriteSongs', + 'genres': 'genres', + 'imdb_top_250_movies': 'imdb-top250-movies', + 'imdb_top_250_tv_shows': 'imdb-top250-tv', + 'imdb_most_popular_movies': 'imdb-popular-movies', + 'imdb_most_popular_tv_shows': 'imdb-popular-tv', + 'imdb_lowest_rated_movies': 'imdb-lowest-rated', + 'imdb_top_english_movies': 'imdb-top-english' +}; + +export const mergeHomeRows = (rows) => { + if (!Array.isArray(rows)) return [...DEFAULT_HOME_ROWS]; + const merged = [...rows]; + let added = false; + for (const def of DEFAULT_HOME_ROWS) { + if (!merged.find((row) => row.id === def.id)) { + merged.push({...def, enabled: false, order: merged.length}); + added = true; + } + } + if (!added) return rows; + return merged; +}; + +// Sections this client doesn't model, meaning plugin rows and any type added later. +// They get handed back verbatim so that writing a layout from the TV never destroys +// them. Null means we haven't read the server's layout yet. +let homeSectionsPassthrough = null; + +export const hasSeenServerLayout = () => homeSectionsPassthrough !== null; + +// Exported so tests can clear the module state between cases. +export const __resetHomeLayoutPassthrough = () => { + homeSectionsPassthrough = null; +}; + +const isBuiltinSection = (section) => + section && section.kind !== 'pluginDynamic' && section.type && section.type !== 'none'; + +// A server type we don't have a row for is left out rather than invented. +const rowForServerType = (type) => + DEFAULT_HOME_ROWS.find((row) => row.id === (SERVER_TO_TV_ROW[type] || type)); + +export const homeRowsFromSections = (sections) => { + if (!Array.isArray(sections) || sections.length === 0) return undefined; + const rows = sections + .filter(isBuiltinSection) + .map((section) => { + const def = rowForServerType(section.type); + return def ? {def, enabled: section.enabled !== false, order: section.order ?? 0} : null; + }) + .filter(Boolean) + .sort((left, right) => left.order - right.order) + .map((entry, index) => ({...entry.def, enabled: entry.enabled, order: index})); + return rows.length > 0 ? mergeHomeRows(rows) : undefined; +}; + +export const homeRowsFromRowOrder = (serverIds) => { + if (!Array.isArray(serverIds) || serverIds.length === 0) return undefined; + const rows = []; + serverIds.forEach((sid, index) => { + const def = rowForServerType(sid); + if (def) rows.push({...def, enabled: true, order: index}); + }); + return mergeHomeRows(rows); +}; + +// The whole layout comes from the first profile that has one, never a row by row merge +// across profiles. +export const homeRowsFromProfile = (serverProfile) => { + if (!serverProfile) return undefined; + const fromSections = homeRowsFromSections(serverProfile.homeSections); + if (fromSections) { + homeSectionsPassthrough = serverProfile.homeSections.filter((s) => !isBuiltinSection(s)); + return fromSections; + } + const fromOrder = homeRowsFromRowOrder(serverProfile.homeRowOrder); + if (fromOrder) { + homeSectionsPassthrough = homeSectionsPassthrough || []; + return fromOrder; + } + return undefined; +}; + +export const homeRowsToSections = (rows) => [ + ...[...rows] + .sort((left, right) => left.order - right.order) + .map((row, index) => ({ + kind: 'builtin', + type: TV_TO_SERVER_ROW[row.id] || row.id, + enabled: !!row.enabled, + order: index + })), + ...(homeSectionsPassthrough || []) +]; + +export const homeRowsToRowOrder = (rows) => [...rows] + .sort((left, right) => left.order - right.order) + .filter((row) => row.enabled) + .map((row) => TV_TO_SERVER_ROW[row.id] || row.id); diff --git a/packages/app/src/utils/homeLayout.test.js b/packages/app/src/utils/homeLayout.test.js new file mode 100644 index 00000000..50f5bba2 --- /dev/null +++ b/packages/app/src/utils/homeLayout.test.js @@ -0,0 +1,167 @@ +import { + DEFAULT_HOME_ROWS, + TV_TO_SERVER_ROW, + __resetHomeLayoutPassthrough, + hasSeenServerLayout, + homeRowsFromProfile, + homeRowsFromRowOrder, + homeRowsFromSections, + homeRowsToRowOrder, + homeRowsToSections +} from './homeLayout'; + +const enabledIds = (rows) => rows.filter((row) => row.enabled).map((row) => row.id); +const rowById = (rows, id) => rows.find((row) => row.id === id); + +beforeEach(() => { + __resetHomeLayoutPassthrough(); +}); + +describe('row id translation', () => { + test('every mapped TV id round-trips through the server name', () => { + Object.entries(TV_TO_SERVER_ROW).forEach(([tvId, serverId]) => { + const rows = homeRowsFromRowOrder([serverId]); + expect(enabledIds(rows)).toContain(tvId); + }); + }); + + test('Recently Added maps to the name the server actually uses', () => { + // The server calls this row 'latestmedia'. A mismatch here doesn't error anywhere, + // the row just quietly disappears. + expect(TV_TO_SERVER_ROW['latest-media']).toBe('latestmedia'); + expect(enabledIds(homeRowsFromRowOrder(['latestmedia']))).toContain('latest-media'); + }); + + test('every default row survives a trip to the server and back', () => { + const allEnabled = DEFAULT_HOME_ROWS.map((row, order) => ({...row, enabled: true, order})); + const restored = homeRowsFromSections(homeRowsToSections(allEnabled)); + expect(enabledIds(restored).sort()).toEqual(enabledIds(allEnabled).sort()); + }); +}); + +describe('homeRowsFromSections', () => { + const sections = [ + {kind: 'builtin', type: 'latestmedia', enabled: true, order: 1}, + {kind: 'builtin', type: 'resume', enabled: true, order: 0}, + {kind: 'builtin', type: 'genres', enabled: false, order: 2} + ]; + + test('honours each row\'s own enabled flag', () => { + const rows = homeRowsFromSections(sections); + expect(rowById(rows, 'latest-media').enabled).toBe(true); + expect(rowById(rows, 'resume').enabled).toBe(true); + expect(rowById(rows, 'genres').enabled).toBe(false); + }); + + test('orders by the server order and renumbers densely', () => { + const rows = homeRowsFromSections(sections); + expect(rows[0].id).toBe('resume'); + expect(rows[1].id).toBe('latest-media'); + expect(rows.map((row) => row.order)).toEqual(rows.map((_, i) => i)); + }); + + test('rows missing from the payload come back disabled, not absent', () => { + const rows = homeRowsFromSections([{kind: 'builtin', type: 'resume', enabled: true, order: 0}]); + expect(rows).toHaveLength(DEFAULT_HOME_ROWS.length); + expect(rowById(rows, 'playlists').enabled).toBe(false); + }); + + test('ignores plugin and unknown rows rather than inventing builtins', () => { + const rows = homeRowsFromSections([ + {kind: 'builtin', type: 'resume', enabled: true, order: 0}, + {kind: 'pluginDynamic', type: 'none', enabled: true, order: 1}, + {kind: 'builtin', type: 'not_a_real_row', enabled: true, order: 2} + ]); + expect(enabledIds(rows)).toEqual(['resume']); + }); + + test('an empty or absent payload yields nothing so the caller can fall back', () => { + expect(homeRowsFromSections([])).toBeUndefined(); + expect(homeRowsFromSections(undefined)).toBeUndefined(); + expect(homeRowsFromSections([{kind: 'pluginDynamic', type: 'none'}])).toBeUndefined(); + }); +}); + +describe('homeRowsFromProfile', () => { + test('prefers homeSections over homeRowOrder', () => { + // homeSections says genres is off while homeRowOrder says it is on. The complete + // view has to win, or a disabled row quietly comes back to life. + const rows = homeRowsFromProfile({ + homeSections: [ + {kind: 'builtin', type: 'resume', enabled: true, order: 0}, + {kind: 'builtin', type: 'genres', enabled: false, order: 1} + ], + homeRowOrder: ['resume', 'genres'] + }); + expect(rowById(rows, 'genres').enabled).toBe(false); + }); + + test('falls back to homeRowOrder when there are no sections', () => { + const rows = homeRowsFromProfile({homeSections: null, homeRowOrder: ['resume', 'latestmedia']}); + expect(enabledIds(rows)).toEqual(['resume', 'latest-media']); + }); + + test('a profile with no layout yields nothing', () => { + expect(homeRowsFromProfile({})).toBeUndefined(); + expect(homeRowsFromProfile(null)).toBeUndefined(); + }); +}); + +describe('writing the layout back', () => { + test('homeSections carries disabled rows; homeRowOrder carries only enabled ones', () => { + const rows = homeRowsFromProfile({ + homeSections: [ + {kind: 'builtin', type: 'resume', enabled: true, order: 0}, + {kind: 'builtin', type: 'genres', enabled: false, order: 1} + ] + }); + const sections = homeRowsToSections(rows); + const order = homeRowsToRowOrder(rows); + + expect(sections.find((s) => s.type === 'genres').enabled).toBe(false); + expect(order).not.toContain('genres'); + expect(order).toContain('resume'); + }); + + test('plugin rows are handed back untouched', () => { + // This client can't render them, but it mustn't delete them either. Writing a + // layout without them would strip them from every other client on the account. + const pluginRow = { + kind: 'pluginDynamic', + type: 'none', + enabled: true, + order: 9, + pluginSource: 'collections', + pluginSection: 'featured' + }; + const rows = homeRowsFromProfile({ + homeSections: [{kind: 'builtin', type: 'resume', enabled: true, order: 0}, pluginRow] + }); + expect(homeRowsToSections(rows)).toContainEqual(pluginRow); + }); + + test('a layout read as homeRowOrder is written back as both views', () => { + const rows = homeRowsFromProfile({homeRowOrder: ['resume']}); + expect(hasSeenServerLayout()).toBe(true); + expect(homeRowsToSections(rows).length).toBeGreaterThan(0); + }); +}); + +describe('write suppression before the layout is known', () => { + test('hasSeenServerLayout is false until a layout has been read', () => { + // The caller uses this to decide whether to send a layout at all. Sending + // homeRowOrder without homeSections makes the server throw away its stored + // sections, so guessing is worse than staying quiet. + expect(hasSeenServerLayout()).toBe(false); + }); + + test('reading any layout flips it', () => { + homeRowsFromProfile({homeSections: [{kind: 'builtin', type: 'resume', enabled: true, order: 0}]}); + expect(hasSeenServerLayout()).toBe(true); + }); + + test('a profile without a layout leaves it unknown', () => { + homeRowsFromProfile({}); + expect(hasSeenServerLayout()).toBe(false); + }); +}); diff --git a/packages/app/src/utils/requestQueue.js b/packages/app/src/utils/requestQueue.js new file mode 100644 index 00000000..906fe34f --- /dev/null +++ b/packages/app/src/utils/requestQueue.js @@ -0,0 +1,58 @@ +/** + * Caps how many requests are in flight at once. + * + * The home screen fans out per library and per row, so a cold load can put 40 or more + * requests on the wire in the same tick. A media server backed by SQLite, which on a NAS + * it usually is, answers that by locking up, spiking CPU and timing out, and the client + * then retries and makes it worse. Queuing costs nothing while traffic is under the + * limit. It is the burst that hurts. + */ + +// Sits under the six connections per host a TV browser opens anyway, which leaves room +// for image loads. Those are img tags and never come through here. +export const DEFAULT_MAX_CONCURRENT = 4; + +/** + * Creates a queue that runs at most maxConcurrent tasks at a time. A limit below 1 is + * treated as 1 so the queue can't deadlock. + */ +export const createRequestQueue = (maxConcurrent = DEFAULT_MAX_CONCURRENT) => { + const limit = Math.max(1, maxConcurrent); + const waiting = []; + let active = 0; + + const pump = () => { + if (active >= limit || waiting.length === 0) return; + const {task, resolve, reject} = waiting.shift(); + active++; + // A task that throws before returning a promise still has to release its slot. + let started; + try { + started = Promise.resolve(task()); + } catch (err) { + started = Promise.reject(err); + } + started.then(resolve, reject).finally(() => { + active--; + pump(); + }); + }; + + return { + run: (task) => new Promise((resolve, reject) => { + waiting.push({task, resolve, reject}); + pump(); + }), + inFlight: () => active, + pending: () => waiting.length + }; +}; + +// One queue for everything aimed at the media server, whether that is Jellyfin or Emby. +// The home rows reach it two ways, through the item endpoints and through the plugin's +// own endpoints, and both land on the same box, so capping one route on its own still +// lets a burst through. Playback and images take other paths and stay unthrottled. +// +// Keep long lived connections out of it. Anything holding a connection open, a settings +// stream for instance, would sit on its slot and starve everything waiting behind it. +export const mediaServerQueue = createRequestQueue(); diff --git a/packages/app/src/utils/requestQueue.test.js b/packages/app/src/utils/requestQueue.test.js new file mode 100644 index 00000000..0a23681c --- /dev/null +++ b/packages/app/src/utils/requestQueue.test.js @@ -0,0 +1,117 @@ +import {createRequestQueue, DEFAULT_MAX_CONCURRENT, mediaServerQueue} from './requestQueue'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('createRequestQueue', () => { + test('never exceeds the concurrency limit', async () => { + const queue = createRequestQueue(2); + let running = 0; + let peak = 0; + const gates = [deferred(), deferred(), deferred(), deferred()]; + + const runs = gates.map((gate) => queue.run(async () => { + running++; + peak = Math.max(peak, running); + await gate.promise; + running--; + })); + + await flush(); + expect(peak).toBe(2); + expect(queue.inFlight()).toBe(2); + expect(queue.pending()).toBe(2); + + gates.forEach((gate) => gate.resolve()); + await Promise.all(runs); + expect(peak).toBe(2); + }); + + test('a finished task lets the next one start', async () => { + const queue = createRequestQueue(1); + const order = []; + const first = deferred(); + + const a = queue.run(async () => { + order.push('a-start'); + await first.promise; + order.push('a-end'); + }); + const b = queue.run(async () => { + order.push('b-start'); + }); + + await flush(); + expect(order).toEqual(['a-start']); + + first.resolve(); + await Promise.all([a, b]); + expect(order).toEqual(['a-start', 'a-end', 'b-start']); + }); + + test('drains every queued task', async () => { + const queue = createRequestQueue(3); + const results = await Promise.all( + Array.from({length: 25}, (_, i) => queue.run(async () => i * 2)) + ); + expect(results).toHaveLength(25); + expect(results[24]).toBe(48); + expect(queue.inFlight()).toBe(0); + expect(queue.pending()).toBe(0); + }); + + test('a rejected task rejects its caller without wedging the queue', async () => { + const queue = createRequestQueue(1); + await expect(queue.run(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom'); + await expect(queue.run(() => Promise.resolve('ok'))).resolves.toBe('ok'); + expect(queue.inFlight()).toBe(0); + }); + + test('a task that throws synchronously does not wedge the queue', async () => { + const queue = createRequestQueue(1); + await expect(queue.run(() => { + throw new Error('sync boom'); + })).rejects.toThrow('sync boom'); + await expect(queue.run(() => Promise.resolve('still works'))).resolves.toBe('still works'); + }); + + test('treats a limit below 1 as 1 rather than deadlocking', async () => { + const queue = createRequestQueue(0); + await expect(queue.run(() => Promise.resolve('ran'))).resolves.toBe('ran'); + }); + + test('ships a default limit that leaves room for image loads', () => { + expect(DEFAULT_MAX_CONCURRENT).toBeGreaterThan(0); + expect(DEFAULT_MAX_CONCURRENT).toBeLessThanOrEqual(6); + }); +}); + +describe('mediaServerQueue', () => { + // A queue per service would let through as many bursts as there are services. + test('holds every caller to one shared cap', async () => { + let running = 0; + let peak = 0; + const gate = deferred(); + const runs = Array.from({length: DEFAULT_MAX_CONCURRENT + 3}, () => mediaServerQueue.run(async () => { + running++; + peak = Math.max(peak, running); + await gate.promise; + running--; + })); + + await flush(); + expect(peak).toBe(DEFAULT_MAX_CONCURRENT); + + gate.resolve(); + await Promise.all(runs); + expect(mediaServerQueue.inFlight()).toBe(0); + }); +}); diff --git a/packages/app/src/utils/seerrHomeRows.js b/packages/app/src/utils/seerrHomeRows.js index 16daac2e..e019f7bb 100644 --- a/packages/app/src/utils/seerrHomeRows.js +++ b/packages/app/src/utils/seerrHomeRows.js @@ -28,7 +28,8 @@ export const MOVIE_STUDIOS = [ ]; export const getSeerrHomeRowConfigs = () => [ - {id: 'myRequests', title: $L('My Requests'), type: 'request', cardType: 'portrait'}, + {id: 'myRequests', title: $L('Recent Requests'), type: 'request', cardType: 'portrait'}, + {id: 'recentlyAdded', title: $L('Recently Added'), type: 'media', cardType: 'portrait'}, {id: 'trending', title: $L('Trending Now'), type: 'media', cardType: 'portrait'}, {id: 'popularMovies', title: $L('Popular Movies'), type: 'media', cardType: 'portrait'}, {id: 'popularTv', title: $L('Popular TV Shows'), type: 'media', cardType: 'portrait'}, @@ -44,6 +45,7 @@ export const getSeerrHomeRowConfigs = () => [ // so seerr rows share the unified home layout with the built-in rows. export const SEERR_SECTION_TO_CONFIG = { seerr_recent_requests: 'myRequests', + seerr_recently_added: 'recentlyAdded', seerr_trending: 'trending', seerr_popular_movies: 'popularMovies', seerr_popular_series: 'popularTv', @@ -68,12 +70,15 @@ const yearOf = (item) => { export const normalizeMediaItem = (item) => { const mediaType = item.media_type || item.mediaType || (item.title ? 'movie' : 'tv'); const poster = item.poster_path || item.posterPath; + const backdrop = item.backdrop_path || item.backdropPath; return { Id: `seerr-${mediaType}-${item.id}`, Name: item.title || item.name, Type: mediaType === 'movie' ? 'Movie' : 'Series', ProductionYear: yearOf(item), + Overview: item.overview || '', _externalPosterUrl: poster ? seerrApi.getImageUrl(poster, 'w342') : null, + _externalBackdropUrl: backdrop ? seerrApi.getImageUrl(backdrop, 'w1280') : null, mediaInfo: {status: item.mediaInfo?.status}, _seerr: true, _seerrType: 'item', @@ -86,11 +91,14 @@ const normalizeRequestItem = (request) => { const media = request.media || {}; const mediaType = request.type || media.mediaType || 'movie'; const poster = media.posterPath || media.poster_path; + const backdrop = media.backdropPath || media.backdrop_path; return { Id: `seerr-${mediaType}-${media.tmdbId}`, Name: media.title || media.name || $L('Unknown'), Type: mediaType === 'movie' ? 'Movie' : 'Series', + Overview: media.overview || request.overview || '', _externalPosterUrl: poster ? seerrApi.getImageUrl(poster, 'w342') : null, + _externalBackdropUrl: backdrop ? seerrApi.getImageUrl(backdrop, 'w1280') : null, mediaInfo: {status: media.status}, _seerr: true, _seerrType: 'item', @@ -132,6 +140,8 @@ export const fetchSeerrHomeRow = async (rowId, {userId} = {}) => { switch (rowId) { case 'trending': return ((await seerrApi.trending(1)).results || []).slice(0, HOME_ROW_LIMIT).map(normalizeMediaItem); + case 'recentlyAdded': + return ((await seerrApi.getRecentlyAdded(HOME_ROW_LIMIT)).results || []).map(normalizeMediaItem); case 'popularMovies': return ((await seerrApi.trendingMovies(1)).results || []).slice(0, HOME_ROW_LIMIT).map(normalizeMediaItem); case 'popularTv': diff --git a/packages/app/src/views/Browse/Browse.js b/packages/app/src/views/Browse/Browse.js index 3c9cfb4f..ca58735d 100644 --- a/packages/app/src/views/Browse/Browse.js +++ b/packages/app/src/views/Browse/Browse.js @@ -2,7 +2,7 @@ import {useState, useEffect, useCallback, useRef, useMemo, useReducer} from 'rea import Spotlight from '@enact/spotlight'; import $L from '@enact/i18n/$L'; import {useAuth} from '../../context/AuthContext'; -import {useSettings, TV_TO_SERVER_ROW} from '../../context/SettingsContext'; +import {useSettings, TV_TO_SERVER_ROW, SERVER_TO_TV_ROW} from '../../context/SettingsContext'; import {useSeerr} from '../../context/SeerrContext'; import {ClassicMediaRow, ModernMediaRow} from '../../components/MediaRow'; import SeerrTileRow from '../../components/SeerrTileRow'; @@ -15,7 +15,7 @@ import {getFromStorage, saveToStorage} from '../../services/storage'; import {HOME_ROW_ITEM_FIELDS, resolveItemsByProviderIds} from '../../services/jellyfinApi'; import {loadSinceYouWatchedRows, loadRewatchItems} from '../../services/homeRecommendations'; import * as connectionPool from '../../services/connectionPool'; -import {getMoonfinMediaBar} from '../../services/seerrApi'; +import * as seerrApi from '../../services/seerrApi'; import {toCssColor} from '../../theme/themeSpec'; import DetailSection from './DetailSection'; import FeaturedBanner from './FeaturedBanner'; @@ -35,7 +35,7 @@ const CACHE_TTL_VOLATILE = 5 * 60 * 1000; const CACHE_TTL_LIBRARIES = 30 * 60 * 1000; const VOLATILE_REFRESH_COOLDOWN_MS = 60 * 1000; const CACHE_SAVE_DEBOUNCE_MS = 3000; -const STORAGE_KEY_BROWSE = 'browse_cache_v3'; +const STORAGE_KEY_BROWSE = 'browse_cache_v4'; let cachedRowData = null; let cachedLibraries = null; @@ -86,8 +86,9 @@ const FAVORITE_ROW_CONFIGS = [ const FAVORITE_ROW_IDS = FAVORITE_ROW_CONFIGS.map((row) => row.id); const getSortOrderFromSortBy = (sortBy) => { - if (sortBy === 'SortName') return 'Ascending'; - if (sortBy === 'Random') return 'Ascending'; + const lower = (sortBy || '').toLowerCase(); + if (lower === 'sortname' || lower === 'name') return 'Ascending'; + if (lower === 'random') return 'Ascending'; return 'Descending'; }; @@ -108,6 +109,27 @@ const getItemGenreNames = (item) => { .filter(Boolean); }; +// Picks an arbitrary but repeatable index for a name, so a genre lands on the same +// representative item every load and the server can serve a thumbnail it has already +// generated. Re-rolling at random asks it to decode and resize artwork it has never seen +// before, every single time. +const stableIndex = (seed, length) => { + if (length <= 0) return 0; + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (Math.imul(hash, 31) + seed.charCodeAt(i)) | 0; + } + return Math.abs(hash) % length; +}; + +const resolveExternalImageUrl = (url, width) => { + if (!url) return null; + if (url.startsWith('/')) { + return seerrApi.getImageUrl(url, width); + } + return url; +}; + const filterItemsByExcludedGenres = (items, excludedGenres) => { const excluded = Array.isArray(excludedGenres) ? excludedGenres.map((genre) => String(genre).trim().toLowerCase()).filter(Boolean) @@ -137,18 +159,46 @@ const browseInitialState = { featuredItems: [], }; +// Merges freshly loaded rows into an existing list by row id. An incoming row wins and +// keeps the position of the row it replaces, and new ids go on the end. Rows arrive in +// waves, the cache first and then each loader, so keeping the existing row would leave a +// stale copy on screen and never let the fresh one through. +function mergeRowsById(existingRows, incomingRows) { + const incoming = new Map(); + incomingRows.forEach((row) => { + if (row && row.id) incoming.set(row.id, row); + }); + const merged = existingRows.map((row) => { + if (!row || !incoming.has(row.id)) return row; + const replacement = incoming.get(row.id); + incoming.delete(row.id); + return replacement; + }); + return [...merged, ...incoming.values()]; +} + function browseReducer(state, action) { switch (action.type) { - case 'SET_INITIAL_DATA': + case 'SET_INITIAL_DATA': { + const unique = []; + const seen = new Set(); + (action.rowData || []).forEach(row => { + if (row && row.id && !seen.has(row.id)) { + seen.add(row.id); + unique.push(row); + } + }); return { ...state, isLoading: false, - allRowData: action.rowData, + allRowData: unique, featuredItems: action.featuredItems || state.featuredItems, }; - case 'APPEND_ROWS': + } + case 'APPEND_ROWS': { if (action.rows.length === 0) return state; - return { ...state, allRowData: [...state.allRowData, ...action.rows] }; + return { ...state, allRowData: mergeRowsById(state.allRowData, action.rows) }; + } case 'REFRESH_VOLATILE': { const prevVolatile = new Map(); state.allRowData.forEach((row) => { @@ -169,8 +219,17 @@ function browseReducer(state, action) { } return { ...state, allRowData: next }; } - case 'SET_ROW_DATA': - return { ...state, allRowData: action.rowData }; + case 'SET_ROW_DATA': { + const unique = []; + const seen = new Set(); + (action.rowData || []).forEach(row => { + if (row && row.id && !seen.has(row.id)) { + seen.add(row.id); + unique.push(row); + } + }); + return { ...state, allRowData: unique }; + } case 'SET_LOADING': if (state.isLoading === action.value) return state; return { ...state, isLoading: action.value }; @@ -184,11 +243,34 @@ function browseReducer(state, action) { } } +// Genre tiles borrow a library item's artwork. Keeping only the fields the card reads +// stops the cache growing for no gain on memory tight TVs. +const stripRepresentativeForCache = (rep) => (rep ? { + Id: rep.Id, + ImageTags: rep.ImageTags, + BackdropImageTags: rep.BackdropImageTags +} : undefined); + const stripItemForCache = (item) => ({ Id: item.Id, Name: item.Name, Type: item.Type, ImageTags: item.ImageTags, + // Everything below is needed to render a card. Anything left out is quietly gone on + // the next load, because a warm cache skips the fetch that would rebuild it. + BackdropImageTags: item.BackdropImageTags, + ProviderIds: item.ProviderIds, + UserRating: item.UserRating, + _representative: stripRepresentativeForCache(item._representative), + _external: item._external, + _externalPosterUrl: item._externalPosterUrl, + _externalBackdropUrl: item._externalBackdropUrl, + _resolvedFromExternal: item._resolvedFromExternal, + _seerr: item._seerr, + _seerrType: item._seerrType, + _seerrMediaType: item._seerrMediaType, + _seerrRaw: item._seerrRaw, + mediaInfo: item.mediaInfo, SeriesName: item.SeriesName, SeriesId: item.SeriesId, ParentIndexNumber: item.ParentIndexNumber, @@ -284,7 +366,7 @@ const Browse = ({ const s = settingsRef.current; if (s.useMoonfinPlugin) { - const mediaBarResult = await getMoonfinMediaBar(serverUrl, accessToken, 'tv'); + const mediaBarResult = await seerrApi.getMoonfinMediaBar(serverUrl, accessToken, 'tv'); if (mediaBarResult?.Items?.length) { items = mediaBarResult.Items; } @@ -454,9 +536,18 @@ const Browse = ({ const filteredRows = useMemo(() => { const enabledRowIds = homeRowsConfig.filter(r => r.enabled).map(r => r.id); + const enabledRowIdsSet = new Set(enabledRowIds); + enabledRowIds.forEach((id) => { + const mappedId = TV_TO_SERVER_ROW[id] || SERVER_TO_TV_ROW[id]; + if (mappedId) enabledRowIdsSet.add(mappedId); + }); const enabledPluginIds = pluginSectionsConfig.filter((section) => section.enabled).map((section) => section.id); const rowOrderMap = new Map(); - homeRowsConfig.forEach((row) => rowOrderMap.set(row.id, row.order)); + homeRowsConfig.forEach((row) => { + rowOrderMap.set(row.id, row.order); + const mappedId = TV_TO_SERVER_ROW[row.id] || SERVER_TO_TV_ROW[row.id]; + if (mappedId) rowOrderMap.set(mappedId, row.order); + }); pluginSectionsConfig.forEach((section, index) => rowOrderMap.set(section.id, (section.order ?? index) + 1000)); const hiddenCWMap = parseHiddenMap(settings.hiddenContinueWatchingItems); @@ -530,7 +621,7 @@ const Browse = ({ }); if (combinedItems.length > 0) { - if (enabledRowIds.includes('resume') || enabledRowIds.includes('nextup')) { + if (enabledRowIdsSet.has('resume') || enabledRowIdsSet.has('nextup')) { result = [{ id: 'continue-nextup', title: $L('Continue Watching'), @@ -545,9 +636,9 @@ const Browse = ({ if (row.id === 'continue-nextup') return true; if (row.isPluginRow) return enabledPluginIds.includes(row.id); if (!isRowVisibleByGates(row.id)) return false; - if (row.isLatestRow) return enabledRowIds.includes('latest-media'); - if (row.isRecentlyReleasedRow) return enabledRowIds.includes('recently-released'); - return enabledRowIds.includes(row.id); + if (row.isLatestRow) return enabledRowIdsSet.has('latest-media') || enabledRowIdsSet.has('latestmedia'); + if (row.isRecentlyReleasedRow) return enabledRowIdsSet.has('recently-released') || enabledRowIdsSet.has('recentlyreleased'); + return enabledRowIdsSet.has(row.id) || enabledRowIdsSet.has(TV_TO_SERVER_ROW[row.id]) || enabledRowIdsSet.has(SERVER_TO_TV_ROW[row.id]); }); } else { const resumeRow = allRowData.find(r => r.id === 'resume'); @@ -571,18 +662,18 @@ const Browse = ({ return enabledPluginIds.includes(row.id); } if (row.id === 'resume' || row.id === 'nextup') { - return enabledRowIds.includes(row.id); + return enabledRowIdsSet.has(row.id); } if (row.isLatestRow) { - return enabledRowIds.includes('latest-media'); + return enabledRowIdsSet.has('latest-media') || enabledRowIdsSet.has('latestmedia'); } if (row.isRecentlyReleasedRow) { - return enabledRowIds.includes('recently-released'); + return enabledRowIdsSet.has('recently-released') || enabledRowIdsSet.has('recentlyreleased'); } if (!isRowVisibleByGates(row.id)) { return false; } - return enabledRowIds.includes(row.id); + return enabledRowIdsSet.has(row.id) || enabledRowIdsSet.has(TV_TO_SERVER_ROW[row.id]) || enabledRowIdsSet.has(SERVER_TO_TV_ROW[row.id]); }); } @@ -872,6 +963,7 @@ const Browse = ({ }, [serverUrl, user?.Id]); useEffect(() => { + let cancelled = false; const loadData = async () => { // IMDb rows are only fetched by fetchAllData, so treat an enabled IMDb list as // dynamic config. Otherwise enabling one shows nothing until the browse cache expires. @@ -910,7 +1002,10 @@ const Browse = ({ } const persistedCache = await loadBrowseCache(); - const hasValidPersistedCache = persistedCache && isCacheValid(persistedCache.timestamp, CACHE_TTL_LIBRARIES); + const hasValidPersistedCache = persistedCache && + isCacheValid(persistedCache.timestamp, CACHE_TTL_LIBRARIES) && + Array.isArray(persistedCache.libraries) && + persistedCache.libraries.length > 0; if (hasValidPersistedCache) { dispatch({type: 'SET_ROW_DATA', rowData: persistedCache.rowData}); @@ -932,7 +1027,7 @@ const Browse = ({ const fetchAllData = async () => { try { - let libs, resumeItems, nextUp, userConfig, randomItems, recentlyPlayed, imdbResults = []; + let libs, resumeItems, nextUp, userConfig, randomItems, recentlyPlayed; if (unifiedMode) { const [libsArray, resumeArray, nextUpArray, randomArray] = await Promise.all([ @@ -949,52 +1044,21 @@ const Browse = ({ recentlyPlayed = null; // IMDb custom rows are single-server only, so imdbResults stays empty in unified mode. } else { - const enabledImdbRows = homeRowsConfig.filter( - (row) => row.enabled && row.id.startsWith('imdb-') - ); - const [results, imdbListResults] = await Promise.all([ - Promise.all([ - api.getLibraries(), - api.getResumeItems(), - api.getNextUp(), - api.getUserConfiguration().catch(() => null), - api.getRandomItems(settings.featuredContentType, settings.featuredItemCount).catch(() => null), - settings.mergeContinueWatchingNextUp ? api.getItems({ - IncludeItemTypes: 'Episode', - Filters: 'IsPlayed', - Recursive: true, - SortBy: 'DatePlayed', - SortOrder: 'Descending', - Limit: 100, - Fields: 'UserData,SeriesId' - }) : Promise.resolve(null) - ]), - Promise.all( - enabledImdbRows.map((row) => { - const serverId = TV_TO_SERVER_ROW[row.id] || row.id; - return api.getCustomRow('imdb', serverId) - .then((res) => { - if (!res || res.success !== true || !Array.isArray(res.items)) { - return { row, items: [] }; - } - // These are external discovery items (no library Id / ImageTags), - // so map them to what the media card can render via _externalPosterUrl. - const items = res.items.map((it) => { - const imdbId = it.providerIds?.Imdb || null; - return { - Id: `imdb-${imdbId || `${serverId}-${it.rank}`}`, - Name: it.name, - Type: it.type, - ProductionYear: it.productionYear, - ProviderIds: {Imdb: imdbId}, - _externalPosterUrl: it.posterUrl || null - }; - }); - return { row, items }; - }) - .catch(() => ({ row, items: [] })); - }) - ) + const results = await Promise.all([ + api.getLibraries().catch(() => ({Items: []})), + api.getResumeItems().catch(() => ({Items: []})), + api.getNextUp().catch(() => ({Items: []})), + api.getUserConfiguration().catch(() => null), + api.getRandomItems(settings.featuredContentType, settings.featuredItemCount).catch(() => null), + settings.mergeContinueWatchingNextUp ? api.getItems({ + IncludeItemTypes: 'Episode', + Filters: 'IsPlayed', + Recursive: true, + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 100, + Fields: 'UserData,SeriesId' + }).catch(() => null) : Promise.resolve(null) ]); libs = results[0].Items || []; resumeItems = results[1]; @@ -1002,7 +1066,6 @@ const Browse = ({ userConfig = results[3]; randomItems = results[4]; recentlyPlayed = results[5]; - imdbResults = imdbListResults; } cachedLibraries = libs; @@ -1078,6 +1141,7 @@ const Browse = ({ } dispatch({type: 'SET_INITIAL_DATA', rowData}); + cachedRowData = [...rowData]; // Populate the Mediabar via the settings-aware loader so it honors // the selected libraries; the server-wide random items are only a // fallback (otherwise excluded libraries leak in). @@ -1093,444 +1157,620 @@ const Browse = ({ return true; }); - let latestResults; - let recentlyReleasedResults; - let collectionsResult = null; - let favoriteResults = []; - let genresResult = null; - let playlistsResult = null; - let audioArtistsResult = null; - let audioAlbumsResult = null; - let audioPlaylistsResult = null; - let resumeAudioResult = null; - let recordingsResult = null; - let pluginRows = []; - let sinceYouWatchedRows = []; - let rewatchItems = null; - - const fetchPluginSectionRow = async (section) => { - if (!section?.enabled) return null; - const spec = parsePluginSpec(section.specJson); - if (!spec || typeof spec !== 'object') return null; - const limit = Number.isFinite(Number(spec.limit)) ? Number(spec.limit) : 20; - const title = section.name || section.displayText || $L('Plugin Section'); - const fields = HOME_ROW_ITEM_FIELDS; + if (unifiedMode) { + const latestResults = await connectionPool.getLatestPerLibraryFromAllServers( + latestItemsExcludes, + EXCLUDED_COLLECTION_TYPES + ); + const newRows = []; + for (const result of latestResults) { + if (result && result.latest?.length > 0) { + const libraryTitle = result.lib._serverName + ? `${result.lib.Name} (${result.lib._serverName})` + : result.lib.Name; + const rowId = `latest-${result.lib.Id}${result.lib._serverName ? '-' + result.lib._serverName : ''}`; + + newRows.push({ + id: rowId, + title: $L('Recently Added in {libraryTitle}').replace('{libraryTitle}', libraryTitle), + items: result.latest, + library: result.lib, + type: result.lib.CollectionType?.toLowerCase() === 'music' ? 'square' : 'portrait', + isLatestRow: true + }); + } + } + dispatch({type: 'APPEND_ROWS', rows: newRows}); + cachedRowData = [...rowData, ...newRows]; + cacheTimestamp = Date.now(); + dispatch({type: 'SET_LOADING', value: false}); + return; + } + + const favoriteSortBy = settings.favoritesRowSortBy || 'SortName'; + const favoriteSortOrder = getSortOrderFromSortBy(favoriteSortBy); + const collectionsSortBy = settings.collectionsRowSortBy || 'SortName'; + const collectionsSortOrder = getSortOrderFromSortBy(collectionsSortBy); + const genresSortBy = settings.genresRowSortBy || 'SortName'; + const genresSortOrder = getSortOrderFromSortBy(genresSortBy); + const genresIncludeTypes = getGenresIncludeTypes(settings.genresRowItemFilter); + const playlistsSortBy = settings.playlistsRowSortBy || 'SortName'; + const playlistsSortOrder = getSortOrderFromSortBy(playlistsSortBy); + const audioRowsSortBy = settings.audioRowsSortBy || 'SortName'; + const audioRowsSortOrder = getSortOrderFromSortBy(audioRowsSortBy); + const audioArtistsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioartists'); + const audioAlbumsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioalbums'); + const audioPlaylistsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioplaylists'); + const resumeAudioEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'resumeaudio'); + const recordingsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'activerecordings'); + const enabledPluginSections = (settings.pluginSections || []).filter((section) => section.enabled); + const sinceYouWatchedIndexes = homeRowsConfig + .filter((row) => row.enabled && row.id.startsWith('sinceyouwatched')) + .map((row) => parseInt(row.id.replace('sinceyouwatched', ''), 10)) + .filter((idx) => idx >= 1) + .sort((a, b) => a - b); + const rewatchEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'rewatch'); + + const appendRows = (rows) => { + if (cancelled || rows.length === 0) return; + dispatch({type: 'APPEND_ROWS', rows}); + cachedRowData = mergeRowsById(cachedRowData || [], rows); + cacheTimestamp = Date.now(); + // Unified mode spans several servers, so its rows never go to the disk cache. + if (!unifiedMode) { + saveBrowseCache(cachedRowData, libs, cachedFeaturedItems); + } + }; + const loadLatestAndRecentlyReleased = async () => { try { - let items = []; - switch (spec.kind) { - case 'recentlyReleasedMovies': { - const result = await api.getItems({ - IncludeItemTypes: 'Movie', - SortBy: 'PremiereDate', - SortOrder: 'Descending', - Recursive: true, - Limit: limit, - Fields: fields - }); - items = result?.Items || []; - break; - } - case 'recentlyReleasedEpisodes': { - const result = await api.getItems({ - IncludeItemTypes: 'Episode', - SortBy: 'PremiereDate', - SortOrder: 'Descending', - Recursive: true, - Limit: limit, - Fields: fields + const [latestResults, recentlyReleasedResults] = await Promise.all([ + Promise.all( + eligibleLibraries.map(lib => + api.getLatest(lib.Id, 16) + .then(latest => ({lib, latest})) + .catch(() => null) + ) + ), + Promise.all( + eligibleLibraries.map(lib => + api.getRecentlyReleased(lib.Id, 16) + .then(latest => ({lib, latest})) + .catch(() => null) + ) + ) + ]); + + const rows = []; + for (const result of latestResults) { + if (result && result.latest?.length > 0) { + const libraryTitle = result.lib.Name; + const rowId = `latest-${result.lib.Id}`; + rows.push({ + id: rowId, + title: $L('Recently Added in {libraryTitle}').replace('{libraryTitle}', libraryTitle), + items: result.latest, + library: result.lib, + type: result.lib.CollectionType?.toLowerCase() === 'music' ? 'square' : 'portrait', + isLatestRow: true }); - items = result?.Items || []; - break; } - case 'watchAgain': { - const result = await api.getItems({ - IncludeItemTypes: 'Movie,Series', - Filters: 'IsPlayed', - SortBy: 'DatePlayed', - SortOrder: 'Descending', - Recursive: true, - Limit: limit, - Fields: fields + } + for (const result of recentlyReleasedResults) { + if (result && result.latest?.Items?.length > 0) { + const libraryTitle = result.lib.Name; + const rowId = `recently-released-${result.lib.Id}`; + rows.push({ + id: rowId, + title: $L('Recently Released in {libraryTitle}').replace('{libraryTitle}', libraryTitle), + items: result.latest.Items, + library: result.lib, + type: result.lib.CollectionType?.toLowerCase() === 'music' ? 'square' : 'portrait', + isRecentlyReleasedRow: true }); - items = result?.Items || []; - break; } - case 'recentlyAddedInLibrary': { - const libraryIds = Array.isArray(spec.libraryIds) ? spec.libraryIds : []; - const responses = await Promise.all( - libraryIds.map((libraryId) => api.getItems({ - ParentId: libraryId, - IncludeItemTypes: 'Movie,Series', - SortBy: 'DateCreated', - SortOrder: 'Descending', - Recursive: true, - Limit: limit, - Fields: fields - }).catch(() => null)) - ); - items = responses.flatMap((response) => response?.Items || []).slice(0, limit); - break; - } - case 'custom': { - const includeItemTypes = Array.isArray(spec.includeItemTypes) - ? spec.includeItemTypes.join(',') - : 'Movie,Series'; - const sortBy = spec.sortBy || 'Random'; - const sortOrder = spec.sortOrderDirection || 'Ascending'; - const params = { - IncludeItemTypes: includeItemTypes, - SortBy: sortBy, - SortOrder: sortOrder, - Recursive: true, - Limit: limit, - Fields: fields - }; - if (spec.type === 'genre' && spec.source) params.Genres = spec.source; - if (spec.type === 'person' && spec.source) params.PersonIds = spec.source; - if (spec.type === 'studio' && spec.source) params.StudioIds = spec.source; - if (spec.type === 'collection' && spec.source) params.ParentId = spec.source; - const result = await api.getItems(params); - items = result?.Items || []; - break; - } - case 'collection': { - const collectionId = spec.collectionId || null; - if (!collectionId) { - items = []; - break; - } - const result = await api.getCollectionItems(collectionId, limit); - items = result?.Items || []; - break; - } - case 'genre': { - const params = { - IncludeItemTypes: spec.includeItemTypes || 'Movie,Series', - SortBy: spec.sortBy || 'SortName', - SortOrder: spec.sortOrder || 'Ascending', - Recursive: true, - Limit: limit, - Fields: fields - }; - if (spec.genreId) { - params.GenreIds = spec.genreId; - } else if (spec.genreName) { - params.Genres = spec.genreName; - } - const result = await api.getItems(params); - items = result?.Items || []; - break; - } - default: - items = []; } + appendRows(rows); + } catch (e) { + console.warn('[Browse] Failed to load latest items:', e); + } + }; - if (items.length === 0) return null; - const cardTypeHint = spec.cardType || spec.section?.CardType || spec.section?.cardType || spec.section?.Layout || spec.section?.layout; - const normalizedCardType = typeof cardTypeHint === 'string' ? cardTypeHint.toLowerCase() : ''; - const viewModeHint = spec.viewMode || spec.section?.ViewMode || spec.section?.viewMode || ''; - const normalizedViewMode = typeof viewModeHint === 'string' ? viewModeHint.toLowerCase() : ''; - let rowType = 'portrait'; - if (normalizedViewMode.includes('portrait')) { - rowType = 'portrait'; - } else if (normalizedViewMode.includes('square')) { - rowType = 'square'; - } else if ( - normalizedViewMode.includes('landscape') || - normalizedViewMode.includes('small') || - normalizedViewMode.includes('backdrop') || - normalizedCardType.includes('landscape') || - normalizedCardType.includes('thumb') || - spec.kind === 'recentlyReleasedEpisodes' - ) { - rowType = 'landscape'; + const loadCollections = async () => { + if (!settings.displayCollectionsRows) return; + try { + const collectionsResult = await api.getCollections(20, collectionsSortBy, collectionsSortOrder).catch(() => null); + if (collectionsResult?.Items?.length > 0) { + appendRows([{ + id: 'collections', + title: $L('Collections'), + items: collectionsResult.Items, + type: 'portrait' + }]); } - return { - id: section.id, - title, - items, - type: rowType, - isPluginRow: true, - pluginSource: section.source - }; - } catch (_error) { - return null; + } catch (e) { + console.warn('[Browse] Failed to load collections:', e); } }; - if (unifiedMode) { - latestResults = await connectionPool.getLatestPerLibraryFromAllServers( - latestItemsExcludes, - EXCLUDED_COLLECTION_TYPES - ); - } else { - const favoriteSortBy = settings.favoritesRowSortBy || 'SortName'; - const favoriteSortOrder = getSortOrderFromSortBy(favoriteSortBy); - const collectionsSortBy = settings.collectionsRowSortBy || 'SortName'; - const collectionsSortOrder = getSortOrderFromSortBy(collectionsSortBy); - const genresSortBy = settings.genresRowSortBy || 'SortName'; - const genresSortOrder = getSortOrderFromSortBy(genresSortBy); - const genresIncludeTypes = getGenresIncludeTypes(settings.genresRowItemFilter); - const playlistsSortBy = settings.playlistsRowSortBy || 'SortName'; - const playlistsSortOrder = getSortOrderFromSortBy(playlistsSortBy); - const audioRowsSortBy = settings.audioRowsSortBy || 'SortName'; - const audioRowsSortOrder = getSortOrderFromSortBy(audioRowsSortBy); - const audioArtistsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioartists'); - const audioAlbumsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioalbums'); - const audioPlaylistsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'audioplaylists'); - const resumeAudioEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'resumeaudio'); - const recordingsEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'activerecordings'); - const enabledPluginSections = (settings.pluginSections || []).filter((section) => section.enabled); - const sinceYouWatchedIndexes = homeRowsConfig - .filter((row) => row.enabled && row.id.startsWith('sinceyouwatched')) - .map((row) => parseInt(row.id.replace('sinceyouwatched', ''), 10)) - .filter((idx) => idx >= 1) - .sort((a, b) => a - b); - const rewatchEnabled = homeRowsConfig.some((row) => row.enabled && row.id === 'rewatch'); - - [latestResults, recentlyReleasedResults, collectionsResult, favoriteResults, genresResult, playlistsResult, audioArtistsResult, audioAlbumsResult, audioPlaylistsResult, resumeAudioResult, recordingsResult, pluginRows, sinceYouWatchedRows, rewatchItems] = await Promise.all([ - Promise.all( - eligibleLibraries.map(lib => - api.getLatest(lib.Id, 16) - .then(latest => ({lib, latest})) - .catch(() => null) - ) - ), - Promise.all( - eligibleLibraries.map(lib => - api.getRecentlyReleased(lib.Id, 16) - .then(latest => ({lib, latest})) - .catch(() => null) - ) - ), - settings.displayCollectionsRows - ? api.getCollections(20, collectionsSortBy, collectionsSortOrder).catch(() => null) - : Promise.resolve(null), - settings.displayFavoritesRows - ? Promise.all( - FAVORITE_ROW_CONFIGS.map((rowConfig) => - api.getItems({ - IncludeItemTypes: rowConfig.includeItemTypes, - Filters: 'IsFavorite', - SortBy: favoriteSortBy, - SortOrder: favoriteSortOrder, - Recursive: true, - Limit: 20, - Fields: HOME_ROW_ITEM_FIELDS - }) - .then((result) => ({rowConfig, result})) - .catch(() => null) - ) + const loadFavorites = async () => { + if (!settings.displayFavoritesRows) return; + try { + const favoriteResults = await Promise.all( + FAVORITE_ROW_CONFIGS.map((rowConfig) => + api.getItems({ + IncludeItemTypes: rowConfig.includeItemTypes, + Filters: 'IsFavorite', + SortBy: favoriteSortBy, + SortOrder: favoriteSortOrder, + Recursive: true, + Limit: 20, + Fields: HOME_ROW_ITEM_FIELDS + }) + .then((result) => ({rowConfig, result})) + .catch(() => null) ) - : Promise.resolve([]), - settings.displayGenresRows - ? api.getGenres(undefined, genresIncludeTypes, genresSortBy, genresSortOrder).catch(() => null) - : Promise.resolve(null), - settings.displayPlaylistsRows - ? api.getPlaylists(playlistsSortBy, playlistsSortOrder).catch(() => null) - : Promise.resolve(null), - audioArtistsEnabled - ? api.getAlbumArtists({Limit: 20, SortBy: audioRowsSortBy, SortOrder: audioRowsSortOrder, Fields: HOME_ROW_ITEM_FIELDS}).catch(() => null) - : Promise.resolve(null), - audioAlbumsEnabled - ? api.getItems({IncludeItemTypes: 'MusicAlbum', Recursive: true, SortBy: audioRowsSortBy, SortOrder: audioRowsSortOrder, Limit: 20, Fields: HOME_ROW_ITEM_FIELDS}).catch(() => null) - : Promise.resolve(null), - audioPlaylistsEnabled - ? api.getPlaylists(audioRowsSortBy, audioRowsSortOrder).catch(() => null) - : Promise.resolve(null), - resumeAudioEnabled - ? api.getResumeAudioItems(20).catch(() => null) - : Promise.resolve(null), - recordingsEnabled - ? api.getLiveTvRecordings().catch(() => null) - : Promise.resolve(null), - Promise.all(enabledPluginSections.map((section) => fetchPluginSectionRow(section))), - sinceYouWatchedIndexes.length - ? loadSinceYouWatchedRows(api, { - sinceYouWatchedSource: settings.sinceYouWatchedSource, - sinceYouWatchedSourceItem: settings.sinceYouWatchedSourceItem, - sinceYouWatchedSourceType: settings.sinceYouWatchedSourceType, - sinceYouWatchedIncludeWatched: settings.sinceYouWatchedIncludeWatched, - tmdbApiKey: settings.tmdbApiKey - }, sinceYouWatchedIndexes, seerrEnabled && seerrAuthenticated).catch(() => []) - : Promise.resolve([]), - rewatchEnabled - ? loadRewatchItems(api, { - rewatchIncludeMovies: settings.rewatchIncludeMovies, - rewatchIncludeShows: settings.rewatchIncludeShows, - rewatchIncludeCollections: settings.rewatchIncludeCollections, - rewatchSortBy: settings.rewatchSortBy - }).catch(() => null) - : Promise.resolve(null) - ]); - } - - const newRows = []; - - for (const result of latestResults) { - if (result && result.latest?.length > 0) { - const libraryTitle = unifiedMode && result.lib._serverName - ? `${result.lib.Name} (${result.lib._serverName})` - : result.lib.Name; - const rowId = `latest-${result.lib.Id}${result.lib._serverName ? '-' + result.lib._serverName : ''}`; - - newRows.push({ - id: rowId, - title: $L('Recently Added in {libraryTitle}').replace('{libraryTitle}', libraryTitle), - items: result.latest, - library: result.lib, - type: result.lib.CollectionType?.toLowerCase() === 'music' ? 'square' : 'portrait', - isLatestRow: true - }); - } - } - - for (const result of recentlyReleasedResults) { - if (result && result.latest.Items?.length > 0) { - const libraryTitle = unifiedMode && result.lib._serverName - ? `${result.lib.Name} (${result.lib._serverName})` - : result.lib.Name; - const rowId = `recently-released-${result.lib.Id}${result.lib._serverName ? '-' + result.lib._serverName : ''}`; - - newRows.push({ - id: rowId, - title: $L('Recently Released in {libraryTitle}').replace('{libraryTitle}', libraryTitle), - items: result.latest.Items, - library: result.lib, - type: result.lib.CollectionType?.toLowerCase() === 'music' ? 'square' : 'portrait', - isRecentlyReleasedRow: true + ); + const rows = []; + favoriteResults.filter(Boolean).forEach((favoriteResult) => { + const items = favoriteResult?.result?.Items || []; + if (items.length === 0) return; + rows.push({ + id: favoriteResult.rowConfig.id, + title: $L(favoriteResult.rowConfig.title), + items, + type: favoriteResult.rowConfig.type + }); }); + appendRows(rows); + } catch (e) { + console.warn('[Browse] Failed to load favorites:', e); } - } - - if (collectionsResult?.Items?.length > 0) { - newRows.push({ - id: 'collections', - title: $L('Collections'), - items: collectionsResult.Items, - type: 'portrait' - }); - } + }; - favoriteResults - .filter(Boolean) - .forEach((favoriteResult) => { - const items = favoriteResult?.result?.Items || []; - if (items.length === 0) return; - newRows.push({ - id: favoriteResult.rowConfig.id, - title: $L(favoriteResult.rowConfig.title), - items, - type: favoriteResult.rowConfig.type - }); - }); + const loadGenres = async () => { + if (!settings.displayGenresRows) return; + try { + const genresResult = await api.getGenres(undefined, genresIncludeTypes, genresSortBy, genresSortOrder).catch(() => null); + if (genresResult?.Items?.length > 0) { + let enrichedItems = genresResult.Items; + const genresSortByLower = (settings.genresRowSortBy || 'SortName').toLowerCase(); + if (genresSortByLower === 'sortname' || genresSortByLower === 'name') { + enrichedItems = [...enrichedItems].sort((a, b) => (a.Name || '').localeCompare(b.Name || '')); + } else if (genresSortByLower === 'random') { + enrichedItems = [...enrichedItems].sort(() => Math.random() - 0.5); + } - if (genresResult?.Items?.length > 0) { - newRows.push({ - id: 'genres', - title: $L('Genres'), - items: genresResult.Items, - type: 'portrait', - isGenreRow: true - }); - } + try { + const genreNames = enrichedItems.map((genre) => genre.Name).filter(Boolean); + // One query, filtered to the genres we actually have. Sorting at + // random turns into ORDER BY RANDOM() on the server, a full scan of + // the item table that no index can help, which is far too expensive + // to run on every home load. Any stable sort avoids it. + const repResult = await api.getItems({ + IncludeItemTypes: genresIncludeTypes, + Recursive: true, + Fields: 'PrimaryImageAspectRatio,Genres,ImageTags,BackdropImageTags', + Genres: genreNames.join('|'), + Limit: Math.min(Math.max(genreNames.length * 8, 50), 300), + SortBy: 'SortName' + }); + const repItems = repResult?.Items || []; + + enrichedItems = enrichedItems.map(genre => { + const genreLower = genre.Name.toLowerCase(); + const matchingItems = repItems.filter(item => + getItemGenreNames(item).includes(genreLower) + ); + const matchingWithBackdrop = matchingItems.filter(item => + item.BackdropImageTags?.length > 0 || item.ImageTags?.Thumb + ); + const pool = matchingWithBackdrop.length > 0 ? matchingWithBackdrop : matchingItems; + const rep = pool.length > 0 ? pool[stableIndex(genre.Name, pool.length)] : null; + + if (rep) { + return { + ...genre, + Type: 'Genre', + _representative: rep + }; + } + return { + ...genre, + Type: 'Genre' + }; + }); - if (playlistsResult?.Items?.length > 0) { - newRows.push({ - id: 'playlists', - title: $L('Playlists'), - items: playlistsResult.Items, - type: 'square' - }); - } + // Fallback resolution for any genres that missed the bulk query + const missingGenres = enrichedItems.filter(g => !g._representative); + if (missingGenres.length > 0) { + const fallbackResults = await Promise.all( + missingGenres.map(async (genre) => { + try { + const res = await api.getItems({ + IncludeItemTypes: genresIncludeTypes, + Recursive: true, + Fields: 'PrimaryImageAspectRatio,Genres,ImageTags,BackdropImageTags', + Genres: genre.Name, + Limit: 1, + SortBy: 'SortName' + }); + return { genreId: genre.Id, rep: res?.Items?.[0] || null }; + } catch (err) { + return { genreId: genre.Id, rep: null }; + } + }) + ); + + enrichedItems = enrichedItems.map(genre => { + if (genre._representative) return genre; + const found = fallbackResults.find(r => r.genreId === genre.Id); + if (found && found.rep) { + return { + ...genre, + Type: 'Genre', + _representative: found.rep + }; + } + return genre; + }); + } + } catch (e) { + console.warn('[Browse] Failed to enrich genres:', e); + } - if (audioArtistsResult?.Items?.length > 0) { - newRows.push({ - id: 'audioartists', - title: $L('Music Artists'), - items: audioArtistsResult.Items, - type: 'square' - }); - } + appendRows([{ + id: 'genres', + title: $L('Genres'), + items: enrichedItems, + type: 'portrait', + isGenreRow: true + }]); + } + } catch (e) { + console.warn('[Browse] Failed to load genres:', e); + } + }; - if (audioAlbumsResult?.Items?.length > 0) { - newRows.push({ - id: 'audioalbums', - title: $L('Music Albums'), - items: audioAlbumsResult.Items, - type: 'square' - }); - } + const loadPlaylistsAndMusic = async () => { + try { + const [playlistsResult, audioArtistsResult, audioAlbumsResult, audioPlaylistsResult, resumeAudioResult, recordingsResult] = await Promise.all([ + settings.displayPlaylistsRows ? api.getPlaylists(playlistsSortBy, playlistsSortOrder).catch(() => null) : Promise.resolve(null), + audioArtistsEnabled ? api.getAlbumArtists({Limit: 20, SortBy: audioRowsSortBy, SortOrder: audioRowsSortOrder, Fields: HOME_ROW_ITEM_FIELDS}).catch(() => null) : Promise.resolve(null), + audioAlbumsEnabled ? api.getItems({IncludeItemTypes: 'MusicAlbum', Recursive: true, SortBy: audioRowsSortBy, SortOrder: audioRowsSortOrder, Limit: 20, Fields: HOME_ROW_ITEM_FIELDS}).catch(() => null) : Promise.resolve(null), + audioPlaylistsEnabled ? api.getPlaylists(audioRowsSortBy, audioRowsSortOrder).catch(() => null) : Promise.resolve(null), + resumeAudioEnabled ? api.getResumeAudioItems(20).catch(() => null) : Promise.resolve(null), + recordingsEnabled ? api.getLiveTvRecordings().catch(() => null) : Promise.resolve(null) + ]); + + const rows = []; + if (playlistsResult?.Items?.length > 0) { + rows.push({ + id: 'playlists', + title: $L('Playlists'), + items: playlistsResult.Items, + type: 'square' + }); + } + if (audioArtistsResult?.Items?.length > 0) { + rows.push({ + id: 'audioartists', + title: $L('Music Artists'), + items: audioArtistsResult.Items, + type: 'square' + }); + } + if (audioAlbumsResult?.Items?.length > 0) { + rows.push({ + id: 'audioalbums', + title: $L('Music Albums'), + items: audioAlbumsResult.Items, + type: 'square' + }); + } + if (audioPlaylistsResult?.Items?.length > 0) { + const audioPlaylists = audioPlaylistsResult.Items.filter(item => item.MediaType === 'Audio'); + if (audioPlaylists.length > 0) { + rows.push({ + id: 'audioplaylists', + title: $L('Music Playlists'), + items: audioPlaylists, + type: 'square' + }); + } + } + if (resumeAudioResult?.Items?.length > 0) { + rows.push({ + id: 'resumeaudio', + title: $L('Continue Listening'), + items: resumeAudioResult.Items, + type: 'square' + }); + } + if (recordingsResult?.Items?.length > 0) { + rows.push({ + id: 'activerecordings', + title: $L('Recordings'), + items: recordingsResult.Items, + type: 'landscape' + }); + } + appendRows(rows); + } catch (e) { + console.warn('[Browse] Failed to load playlists/music:', e); + } + }; - if (audioPlaylistsResult?.Items?.length > 0) { - const audioPlaylists = audioPlaylistsResult.Items.filter(item => item.MediaType === 'Audio'); - if (audioPlaylists.length > 0) { - newRows.push({ - id: 'audioplaylists', - title: $L('Music Playlists'), - items: audioPlaylists, - type: 'square' + const loadImdbRows = async () => { + try { + const enabledImdbRows = homeRowsConfig.filter( + (row) => row.enabled && (row.id.startsWith('imdb-') || row.id.startsWith('imdb_')) + ); + if (enabledImdbRows.length === 0) return; + const imdbListResults = await Promise.all( + enabledImdbRows.map((row) => { + const serverId = TV_TO_SERVER_ROW[row.id] || row.id; + return api.getCustomRow('imdb', serverId) + .then((res) => { + if (!res || res.success !== true || !Array.isArray(res.items)) { + return { row, items: [] }; + } + const items = res.items.map((it) => { + const imdbId = it.providerIds?.Imdb || null; + const mediaType = it.type === 'Series' ? 'tv' : 'movie'; + return { + Id: `imdb-${imdbId || `${serverId}-${it.rank}`}`, + Name: it.name, + Type: it.type === 'Series' ? 'Series' : 'Movie', + ProductionYear: it.productionYear, + ProviderIds: {Imdb: imdbId, Tmdb: it.providerIds?.Tmdb}, + Overview: it.overview || null, + _externalPosterUrl: resolveExternalImageUrl(it.posterUrl, 'w342'), + _externalBackdropUrl: resolveExternalImageUrl(it.backdropUrl, 'w780'), + _external: true, + _serverName: 'IMDb', + mediaInfo: {}, + _seerr: true, + _seerrType: 'item', + _seerrMediaType: mediaType, + _seerrRaw: it.providerIds?.Tmdb ? {mediaId: Number(it.providerIds.Tmdb), mediaType} : null + }; + }); + return { row, items }; + }) + .catch(() => ({ row, items: [] })); + }) + ); + const rows = []; + imdbListResults.forEach((res) => { + if (res.items?.length > 0) { + rows.push({ + id: res.row.id, + title: $L(res.row.name), + items: res.items, + type: 'portrait' + }); + } }); + appendRows(rows); + } catch (e) { + console.warn('[Browse] Failed to load IMDb rows:', e); } - } + }; - if (resumeAudioResult?.Items?.length > 0) { - newRows.push({ - id: 'resumeaudio', - title: $L('Continue Listening'), - items: resumeAudioResult.Items, - type: 'square' - }); - } + const loadPluginsAndRecos = async () => { + const fetchPluginSectionRow = async (section) => { + if (!section?.enabled) return null; + const spec = parsePluginSpec(section.specJson); + if (!spec || typeof spec !== 'object') return null; + const limit = Number.isFinite(Number(spec.limit)) ? Number(spec.limit) : 20; + const title = section.name || section.displayText || $L('Plugin Section'); + const fields = HOME_ROW_ITEM_FIELDS; + + try { + let items = []; + switch (spec.kind) { + case 'recentlyReleasedMovies': { + const result = await api.getItems({ + IncludeItemTypes: 'Movie', + SortBy: 'PremiereDate', + SortOrder: 'Descending', + Recursive: true, + Limit: limit, + Fields: fields + }); + items = result?.Items || []; + break; + } + case 'recentlyReleasedEpisodes': { + const result = await api.getItems({ + IncludeItemTypes: 'Episode', + SortBy: 'PremiereDate', + SortOrder: 'Descending', + Recursive: true, + Limit: limit, + Fields: fields + }); + items = result?.Items || []; + break; + } + case 'watchAgain': { + const result = await api.getItems({ + IncludeItemTypes: 'Movie,Series', + Filters: 'IsPlayed', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Recursive: true, + Limit: limit, + Fields: fields + }); + items = result?.Items || []; + break; + } + case 'recentlyAddedInLibrary': { + const libraryIds = Array.isArray(spec.libraryIds) ? spec.libraryIds : []; + const responses = await Promise.all( + libraryIds.map((libraryId) => api.getItems({ + ParentId: libraryId, + IncludeItemTypes: 'Movie,Series', + SortBy: 'DateCreated', + SortOrder: 'Descending', + Recursive: true, + Limit: limit, + Fields: fields + }).catch(() => null)) + ); + items = responses.flatMap((response) => response?.Items || []).slice(0, limit); + break; + } + case 'custom': { + const includeItemTypes = Array.isArray(spec.includeItemTypes) + ? spec.includeItemTypes.join(',') + : 'Movie,Series'; + const sortBy = spec.sortBy || 'Random'; + const sortOrder = spec.sortOrderDirection || 'Ascending'; + const params = { + IncludeItemTypes: includeItemTypes, + SortBy: sortBy, + SortOrder: sortOrder, + Recursive: true, + Limit: limit, + Fields: fields + }; + if (spec.type === 'genre' && spec.source) params.Genres = spec.source; + if (spec.type === 'person' && spec.source) params.PersonIds = spec.source; + if (spec.type === 'studio' && spec.source) params.StudioIds = spec.source; + if (spec.type === 'collection' && spec.source) params.ParentId = spec.source; + const result = await api.getItems(params); + items = result?.Items || []; + break; + } + case 'collection': { + const collectionId = spec.collectionId || null; + if (!collectionId) { + items = []; + break; + } + const result = await api.getCollectionItems(collectionId, limit); + items = result?.Items || []; + break; + } + case 'genre': { + const params = { + IncludeItemTypes: spec.includeItemTypes || 'Movie,Series', + SortBy: spec.sortBy || 'SortName', + SortOrder: spec.sortOrder || 'Ascending', + Recursive: true, + Limit: limit, + Fields: fields + }; + if (spec.genreId) { + params.GenreIds = spec.genreId; + } else if (spec.genreName) { + params.Genres = spec.genreName; + } + const result = await api.getItems(params); + items = result?.Items || []; + break; + } + default: + items = []; + } - if (recordingsResult?.Items?.length > 0) { - newRows.push({ - id: 'activerecordings', - title: $L('Recordings'), - items: recordingsResult.Items, - type: 'landscape' - }); - } + if (items.length === 0) return null; + const cardTypeHint = spec.cardType || spec.section?.CardType || spec.section?.cardType || spec.section?.Layout || spec.section?.layout; + const normalizedCardType = typeof cardTypeHint === 'string' ? cardTypeHint.toLowerCase() : ''; + const viewModeHint = spec.viewMode || spec.section?.ViewMode || spec.section?.viewMode || ''; + const normalizedViewMode = typeof viewModeHint === 'string' ? viewModeHint.toLowerCase() : ''; + let rowType = 'portrait'; + if (normalizedViewMode.includes('portrait')) { + rowType = 'portrait'; + } else if (normalizedViewMode.includes('square')) { + rowType = 'square'; + } else if ( + normalizedViewMode.includes('landscape') || + normalizedViewMode.includes('small') || + normalizedViewMode.includes('backdrop') || + normalizedCardType.includes('landscape') || + normalizedCardType.includes('thumb') || + spec.kind === 'recentlyReleasedEpisodes' + ) { + rowType = 'landscape'; + } + return { + id: section.id, + title, + items, + type: rowType, + isPluginRow: true, + pluginSource: section.source + }; + } catch (_error) { + return null; + } + }; - imdbResults.forEach((res) => { - if (res.items?.length > 0) { - newRows.push({ - id: res.row.id, - title: $L(res.row.name), - items: res.items, - type: 'portrait' + try { + const [pluginRows, sinceYouWatchedRows, rewatchItems] = await Promise.all([ + Promise.all(enabledPluginSections.map((section) => fetchPluginSectionRow(section))), + sinceYouWatchedIndexes.length + ? loadSinceYouWatchedRows(api, { + sinceYouWatchedSource: settings.sinceYouWatchedSource, + sinceYouWatchedSourceItem: settings.sinceYouWatchedSourceItem, + sinceYouWatchedSourceType: settings.sinceYouWatchedSourceType, + sinceYouWatchedIncludeWatched: settings.sinceYouWatchedIncludeWatched, + tmdbApiKey: settings.tmdbApiKey + }, sinceYouWatchedIndexes, seerrEnabled && seerrAuthenticated).catch(() => []) + : Promise.resolve([]), + rewatchEnabled + ? loadRewatchItems(api, { + rewatchIncludeMovies: settings.rewatchIncludeMovies, + rewatchIncludeShows: settings.rewatchIncludeShows, + rewatchIncludeCollections: settings.rewatchIncludeCollections, + rewatchSortBy: settings.rewatchSortBy + }).catch(() => null) + : Promise.resolve(null) + ]); + + const rows = []; + pluginRows.filter(Boolean).forEach((pluginRow) => rows.push(pluginRow)); + sinceYouWatchedRows.forEach((row) => { + rows.push({ + id: row.id, + title: $L('Because you watched {name}').replace('{name}', row.seedName), + items: row.items, + type: 'portrait', + isOnlineRecoRow: row.isSeerr === true + }); }); + if (rewatchItems && rewatchItems.length > 0) { + rows.push({ + id: 'rewatch', + title: $L('Rewatch'), + items: rewatchItems, + type: 'portrait' + }); + } + appendRows(rows); + } catch (e) { + console.warn('[Browse] Failed to load plugins/recos:', e); } - }); - - pluginRows.filter(Boolean).forEach((pluginRow) => newRows.push(pluginRow)); - - sinceYouWatchedRows.forEach((row) => { - newRows.push({ - id: row.id, - title: $L('Because you watched {name}').replace('{name}', row.seedName), - items: row.items, - type: 'portrait', - isOnlineRecoRow: row.isSeerr === true - }); - }); - - if (rewatchItems && rewatchItems.length > 0) { - newRows.push({ - id: 'rewatch', - title: $L('Rewatch'), - items: rewatchItems, - type: 'portrait' - }); - } - - dispatch({type: 'APPEND_ROWS', rows: newRows}); - cachedRowData = [...rowData, ...newRows]; - cacheTimestamp = Date.now(); + }; - if (!unifiedMode && newRows.length > 0) { - saveBrowseCache(cachedRowData, libs, cachedFeaturedItems); + dispatch({type: 'SET_LOADING', value: false}); + // Each loader appends its rows as it finishes. They start together and their + // requests line up in the media server queue, so holding the later ones back + // would only delay those rows without easing the load. + if (!cancelled) { + [ + loadLatestAndRecentlyReleased, + loadCollections, + loadFavorites, + loadGenres, + loadPlaylistsAndMusic, + loadImdbRows, + loadPluginsAndRecos + ].forEach((loader) => loader()); } } catch (err) { @@ -1541,6 +1781,9 @@ const Browse = ({ }; loadData(); + return () => { + cancelled = true; + }; }, [ api, serverUrl, @@ -1585,9 +1828,18 @@ const Browse = ({ if (browseMode === 'featured') return ''; if (!focusedItemForBackdrop || isLegacy || settings.showHomeBackdrop === false) return ''; - const backdropId = getBackdropId(focusedItemForBackdrop); + if (focusedItemForBackdrop._externalBackdropUrl) { + return focusedItemForBackdrop._externalBackdropUrl; + } + + let targetItem = focusedItemForBackdrop; + if (focusedItemForBackdrop.Type === 'Genre' && focusedItemForBackdrop._representative) { + targetItem = focusedItemForBackdrop._representative; + } + + const backdropId = getBackdropId(targetItem); if (!backdropId) return ''; - const itemUrl = getItemServerUrl(focusedItemForBackdrop); + const itemUrl = getItemServerUrl(targetItem); return getImageUrl(itemUrl, backdropId, 'Backdrop', {maxWidth: 1280, quality: 80}); }, [browseMode, focusedItemForBackdrop, isLegacy, settings.showHomeBackdrop, getItemServerUrl]); @@ -1718,38 +1970,84 @@ const Browse = ({ const presetConfigs = getExternalHomeRowConfigs(); (async () => { - const presetRows = await Promise.all(enabledPresets.map(async (id) => { - const cfg = presetConfigs.find((c) => c.id === id); - if (!cfg) return null; - const items = await fetchExternalPresetRow(id); - if (!items.length) return null; - const resolved = await resolveItemsByProviderIds(items); - return {id, title: cfg.title, items: resolved, isExternalRow: true}; - })); + try { + const presetData = await Promise.all(enabledPresets.map(async (id) => { + const cfg = presetConfigs.find((c) => c.id === id); + if (!cfg) return null; + const items = await fetchExternalPresetRow(id); + return {id, title: cfg.title, items: items || []}; + })); - const builtCustomRows = await Promise.all(customRows.map(async (row) => { - const items = await fetchCustomHomeRow(row); - if (!items.length) return null; - const resolved = await resolveItemsByProviderIds(items); - return {id: `external-${row.id}`, title: row.name || row.title || $L('Custom'), items: resolved, isExternalRow: true, isCustomRow: true}; - })); + const customData = await Promise.all(customRows.map(async (row) => { + const items = await fetchCustomHomeRow(row); + return {id: `external-${row.id}`, title: row.name || row.title || $L('Custom'), items: items || [], isCustomRow: true}; + })); - const calendarSettings = { - mergeRadarrSonarrCalendars: settings.mergeRadarrSonarrCalendars, - radarrCalendarShowCinema: settings.radarrCalendarShowCinema, - radarrCalendarShowDigital: settings.radarrCalendarShowDigital, - radarrCalendarShowPhysical: settings.radarrCalendarShowPhysical, - radarrCalendarShowDate: settings.radarrCalendarShowDate, - sonarrCalendarShowDate: settings.sonarrCalendarShowDate, - sonarrCalendarShowEpisodeInfo: settings.sonarrCalendarShowEpisodeInfo - }; - const calendarRows = calendarsEnabled ? await fetchCalendarRows(calendarSettings, {radarrEnabled, sonarrEnabled}) : []; - const resolvedCalendarRows = await Promise.all(calendarRows.map(async (row) => ({ - ...row, - items: await resolveItemsByProviderIds(row.items) - }))); + const calendarSettings = { + mergeRadarrSonarrCalendars: settings.mergeRadarrSonarrCalendars, + radarrCalendarShowCinema: settings.radarrCalendarShowCinema, + radarrCalendarShowDigital: settings.radarrCalendarShowDigital, + radarrCalendarShowPhysical: settings.radarrCalendarShowPhysical, + radarrCalendarShowDate: settings.radarrCalendarShowDate, + sonarrCalendarShowDate: settings.sonarrCalendarShowDate, + sonarrCalendarShowEpisodeInfo: settings.sonarrCalendarShowEpisodeInfo + }; + const calendarRows = calendarsEnabled ? await fetchCalendarRows(calendarSettings, {radarrEnabled, sonarrEnabled}) : []; + + const allRows = [ + ...presetData, + ...customData, + ...calendarRows.map(r => ({...r, isCalendarRow: true})) + ].filter(r => r && r.items && r.items.length > 0); + + const allItemsToResolve = []; + const rowIndices = []; + for (const r of allRows) { + rowIndices.push({ + start: allItemsToResolve.length, + count: r.items.length + }); + allItemsToResolve.push(...r.items); + } + + const resolvedAllItems = await resolveItemsByProviderIds(allItemsToResolve); + + const presetRows = []; + const builtCustomRows = []; + const resolvedCalendarRows = []; + + for (let i = 0; i < allRows.length; i++) { + const r = allRows[i]; + const sliceInfo = rowIndices[i]; + const resolvedItems = resolvedAllItems.slice(sliceInfo.start, sliceInfo.start + sliceInfo.count); + + if (r.isCalendarRow) { + resolvedCalendarRows.push({ + ...r, + items: resolvedItems + }); + } else { + const resolvedRow = { + id: r.id, + title: r.title, + items: resolvedItems, + isExternalRow: true, + isCustomRow: r.isCustomRow + }; + if (r.isCustomRow) { + builtCustomRows.push(resolvedRow); + } else { + presetRows.push(resolvedRow); + } + } + } - if (!cancelled) setExternalRows([...presetRows, ...builtCustomRows, ...resolvedCalendarRows].filter(Boolean)); + if (!cancelled) { + setExternalRows([...presetRows, ...builtCustomRows, ...resolvedCalendarRows].filter(Boolean)); + } + } catch (err) { + console.warn('[Browse] Failed to fetch and resolve external rows:', err); + } })(); return () => { diff --git a/packages/app/src/views/Browse/DetailSection.js b/packages/app/src/views/Browse/DetailSection.js index 6d198580..def8413a 100644 --- a/packages/app/src/views/Browse/DetailSection.js +++ b/packages/app/src/views/Browse/DetailSection.js @@ -36,6 +36,9 @@ const DetailSection = forwardRef(({ focusItemTimeoutRef.current = setTimeout(() => { setFocusedItem(item); onFocusedItemChange?.(item); + if (item._seerr || item._external || item._resolvedFromExternal) { + return; + } const needsBackdrop = !item.BackdropImageTags?.length && !item.ParentBackdropImageTags?.length; const needsProviderIds = !item.ProviderIds; if (needsBackdrop || needsProviderIds) { @@ -70,6 +73,9 @@ const DetailSection = forwardRef(({ {focusedItem.Type === 'Episode' ? focusedItem.SeriesName : focusedItem.Name}
+ {focusedItem.UserRating && ( + {focusedItem.UserRating} + )} {focusedItem.ProductionYear && ( {focusedItem.ProductionYear} )} diff --git a/packages/app/src/views/SeerrDiscover/SeerrDiscover.js b/packages/app/src/views/SeerrDiscover/SeerrDiscover.js index d52b55af..8722f9d3 100644 --- a/packages/app/src/views/SeerrDiscover/SeerrDiscover.js +++ b/packages/app/src/views/SeerrDiscover/SeerrDiscover.js @@ -23,7 +23,7 @@ const ITEMS_PER_PAGE = 9; let _rowConfigs; const getRowConfigs = () => (_rowConfigs ??= [ - {id: 'myRequests', title: $L('My Requests'), type: 'request'}, + {id: 'myRequests', title: $L('Recent Requests'), type: 'request'}, {id: 'trending', title: $L('Trending Now'), type: 'media', fetchFn: 'trending'}, {id: 'popularMovies', title: $L('Popular Movies'), type: 'media', fetchFn: 'trendingMovies'}, {id: 'popularTv', title: $L('Popular TV Shows'), type: 'media', fetchFn: 'trendingTv'}, diff --git a/packages/app/src/views/Settings/Settings.js b/packages/app/src/views/Settings/Settings.js index e0ce37bc..d7000e0f 100644 --- a/packages/app/src/views/Settings/Settings.js +++ b/packages/app/src/views/Settings/Settings.js @@ -605,7 +605,12 @@ const buildCollectionPluginSections = (collections, sortBy, sortOrder) => { }; const buildGenrePluginSections = (genres, includeItemTypes, sortBy, sortOrder) => { - const items = Array.isArray(genres) ? genres : []; + let items = Array.isArray(genres) ? genres : []; + if (sortBy === 'SortName' || sortBy === 'Name') { + items = [...items].sort((a, b) => (a.Name || '').localeCompare(b.Name || '')); + } else if (sortBy === 'Random') { + items = [...items].sort(() => Math.random() - 0.5); + } return items.map((genre, index) => { const genreId = genre?.Id || genre?.Name || `genre-${index + 1}`; const genreName = genre?.Name || $L('Genre {index}').replace('{index}', String(index + 1));