From 34d9b8a9ddcb0d6f28cba0611405403b62255866 Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 9 Apr 2026 04:31:37 +0300 Subject: [PATCH] notification bell shows newest episode per series with series context --- src/components/layout/AppHeader.tsx | 84 ++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index b7dcdfe..0d7c11b 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -69,7 +69,9 @@ export default function AppHeader({ pinned, onTogglePin }: Props) { }) const notifsRef = useRef(null) - // Poll recently-added items for the notification bell + // Poll recently-added episodes + movies for the notification bell. + // Episodes are deduplicated by series so a full-season drop doesn't + // flood the list -- only the newest episode per series is shown. useEffect(() => { const api = jellyfinClient.getApi() if (!api) return @@ -77,16 +79,46 @@ export default function AppHeader({ pinned, onTogglePin }: Props) { try { const auth = jellyfinClient.getAuthState() if (!auth?.userId) return - const res = await getItemsApi(api!).getItems({ - userId: auth.userId, - sortBy: ['DateCreated'], - sortOrder: ['Descending'], - limit: 15, - recursive: true, - includeItemTypes: ['Movie', 'Series'], - fields: ['DateCreated', 'PrimaryImageAspectRatio'], - } as any) - setNotifs(res.data.Items || []) + const [movieRes, episodeRes] = await Promise.all([ + getItemsApi(api!).getItems({ + userId: auth.userId, + sortBy: ['DateCreated'], + sortOrder: ['Descending'], + limit: 10, + recursive: true, + includeItemTypes: ['Movie'], + fields: ['DateCreated', 'PrimaryImageAspectRatio'], + } as any), + getItemsApi(api!).getItems({ + userId: auth.userId, + sortBy: ['DateCreated'], + sortOrder: ['Descending'], + limit: 50, + recursive: true, + includeItemTypes: ['Episode'], + fields: ['DateCreated', 'PrimaryImageAspectRatio', 'SeriesName', 'SeriesId', 'ParentIndexNumber', 'IndexNumber'], + } as any), + ]) + const movies = (movieRes.data.Items || []) as any[] + const episodes = (episodeRes.data.Items || []) as any[] + // Deduplicate episodes by series -- keep only the newest per show + const seenSeries = new Set() + const dedupedEpisodes: any[] = [] + for (const ep of episodes) { + const sid = ep.SeriesId || ep.SeriesName + if (!sid || seenSeries.has(sid)) continue + seenSeries.add(sid) + dedupedEpisodes.push(ep) + } + // Interleave: one movie, one episode, one movie... capped at 15 + const interleaved: any[] = [] + let m = 0, e = 0 + while (interleaved.length < 15 && (m < movies.length || e < dedupedEpisodes.length)) { + if (m < movies.length) interleaved.push(movies[m++]) + if (interleaved.length >= 15) break + if (e < dedupedEpisodes.length) interleaved.push(dedupedEpisodes[e++]) + } + setNotifs(interleaved) } catch { /* ignore */ } } poll() @@ -325,6 +357,13 @@ export default function AppHeader({ pinned, onTogglePin }: Props) { ) : ( notifs.map((entry: any) => { const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt) + const isEpisode = entry.Type === 'Episode' + const seriesName = entry.SeriesName + const season = entry.ParentIndexNumber + const epNum = entry.IndexNumber + const epLabel = isEpisode && (season != null || epNum != null) + ? `S${season ?? '?'}E${epNum ?? '?'}${entry.Name ? ` · ${entry.Name}` : ''}` + : null return ( )