notification bell shows newest episode per series with series context

This commit is contained in:
2026-04-09 04:31:37 +03:00
parent ac552066fe
commit 34d9b8a9dd
+66 -18
View File
@@ -69,7 +69,9 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
}) })
const notifsRef = useRef<HTMLDivElement>(null) const notifsRef = useRef<HTMLDivElement>(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(() => { useEffect(() => {
const api = jellyfinClient.getApi() const api = jellyfinClient.getApi()
if (!api) return if (!api) return
@@ -77,16 +79,46 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
try { try {
const auth = jellyfinClient.getAuthState() const auth = jellyfinClient.getAuthState()
if (!auth?.userId) return if (!auth?.userId) return
const res = await getItemsApi(api!).getItems({ const [movieRes, episodeRes] = await Promise.all([
userId: auth.userId, getItemsApi(api!).getItems({
sortBy: ['DateCreated'], userId: auth.userId,
sortOrder: ['Descending'], sortBy: ['DateCreated'],
limit: 15, sortOrder: ['Descending'],
recursive: true, limit: 10,
includeItemTypes: ['Movie', 'Series'], recursive: true,
fields: ['DateCreated', 'PrimaryImageAspectRatio'], includeItemTypes: ['Movie'],
} as any) fields: ['DateCreated', 'PrimaryImageAspectRatio'],
setNotifs(res.data.Items || []) } 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<string>()
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 */ } } catch { /* ignore */ }
} }
poll() poll()
@@ -325,6 +357,13 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
) : ( ) : (
notifs.map((entry: any) => { notifs.map((entry: any) => {
const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt) 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 ( return (
<button <button
key={entry.Id} key={entry.Id}
@@ -334,22 +373,31 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
setNotifsOpen(false) setNotifsOpen(false)
} }
}} }}
className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-center gap-2.5 ${isUnread ? 'bg-white/[0.03]' : ''}`} className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-start gap-2.5 ${isUnread ? 'bg-white/[0.03]' : ''}`}
> >
{entry.Type === 'Series' || entry.Type === 'Episode' ? ( {isEpisode ? (
<Tv size={13} className="text-accent shrink-0" /> <Tv size={13} className="text-accent shrink-0 mt-0.5" />
) : ( ) : (
<Film size={13} className="text-accent shrink-0" /> <Film size={13} className="text-accent shrink-0 mt-0.5" />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-text-2 truncate font-medium">{entry.Name}</p> {isEpisode && seriesName ? (
<>
<p className="text-text-2 truncate font-medium">{seriesName}</p>
{epLabel && (
<p className="text-text-3 truncate">{epLabel}</p>
)}
</>
) : (
<p className="text-text-2 truncate font-medium">{entry.Name}</p>
)}
<p className="text-text-4 tabular-nums mt-0.5"> <p className="text-text-4 tabular-nums mt-0.5">
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''} {entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
{entry.ProductionYear ? ` · ${entry.ProductionYear}` : ''} {entry.ProductionYear && !isEpisode ? ` · ${entry.ProductionYear}` : ''}
</p> </p>
</div> </div>
{isUnread && ( {isUnread && (
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0 mt-1.5" />
)} )}
</button> </button>
) )