diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1fc55f155..b7e780b4f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "مباشر", "TYPE_MOVIE": "فيلم", "TYPE_SERIES": "مسلسل", + "SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}", "NOT_YET_SYNCED": "لم تتم المزامنة بعد" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0a8ea6cae..6a151fdf1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "مباشر", "TYPE_MOVIE": "فيلم", "TYPE_SERIES": "سلسلة", + "SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}", "NOT_YET_SYNCED": "مازال ما تزامنش" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index dd7a84458..4a406a4ac 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Прамы эфір", "TYPE_MOVIE": "Фільм", "TYPE_SERIES": "Серыял", + "SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}", "NOT_YET_SYNCED": "Яшчэ не сінхранізавана" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index aed04b4ac..0feac6aeb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}", "NOT_YET_SYNCED": "Noch nicht synchronisiert" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2c7aa3ae1..51993f773 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Ζωντανά", "TYPE_MOVIE": "Ταινία", "TYPE_SERIES": "Σειρά", + "SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}", "NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fb69a04b6..8d4bc009b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Movie", "TYPE_SERIES": "Series", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Not yet synced" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4c5d8737d..e7dee60af 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "En vivo", "TYPE_MOVIE": "Película", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Aún no sincronizado" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 298b822d5..ce46ce76a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Direct", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Série", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Pas encore synchronisé" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0d108a6c1..8d8e5f4f2 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serie", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Non ancora sincronizzato" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 576e8ad9a..5ebf361c4 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "ライブ", "TYPE_MOVIE": "映画", "TYPE_SERIES": "シリーズ", + "SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}", "NOT_YET_SYNCED": "未同期" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 8a1f43f85..ce94623ca 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "라이브", "TYPE_MOVIE": "영화", "TYPE_SERIES": "시리즈", + "SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화", "NOT_YET_SYNCED": "아직 동기화되지 않음" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 74a39ec26..5af92d3b4 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Live", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Series", + "SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}", "NOT_YET_SYNCED": "Nog niet gesynchroniseerd" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 49de2a00a..4731dfc4f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Na żywo", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Serial", + "SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}", "NOT_YET_SYNCED": "Jeszcze niezsynchronizowane" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7868e0dd9..15039fbbc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Ao vivo", "TYPE_MOVIE": "Filme", "TYPE_SERIES": "Série", + "SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}", "NOT_YET_SYNCED": "Ainda não sincronizado" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 61a5a301d..bfd900760 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "ТВ", "TYPE_MOVIE": "Фильм", "TYPE_SERIES": "Сериал", + "SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}", "NOT_YET_SYNCED": "Еще не синхронизировано" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ccfe6aa61..9113ae998 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "Canlı", "TYPE_MOVIE": "Film", "TYPE_SERIES": "Dizi", + "SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}", "NOT_YET_SYNCED": "Henüz eşitlenmedi" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 79f2ba4b1..304df9989 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "直播", "TYPE_MOVIE": "电影", "TYPE_SERIES": "剧集", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "尚未同步" }, "SHELL": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 159af6f57..f36ae3913 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -872,6 +872,7 @@ "TYPE_LIVE": "直播", "TYPE_MOVIE": "電影", "TYPE_SERIES": "影集", + "SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}", "NOT_YET_SYNCED": "尚未同步" }, "SHELL": { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 24657818d..9f304671b 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -619,6 +619,87 @@ describe('DashboardDataService', () => { const seriesPos = service.getPlaybackPositionForItem(series!); expect(seriesPos?.contentType).toBe('episode'); expect(seriesPos?.positionSeconds).toBe(720); + // Season/episode metadata travels with the position so cards can + // render an "S2 · E5" badge without an extra round-trip. + expect(seriesPos?.seasonNumber).toBe(2); + expect(seriesPos?.episodeNumber).toBe(4); + }); + + it('resolves the latest episode position for series whose recent_items row carries the series id', async () => { + // The Xtream series landing page records the recent item with the + // SERIES id, while each played episode saves its position keyed by + // the EPISODE id (with seriesXtreamId pointing back). The lookup + // must follow seriesXtreamId so both card shapes show progress. + dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ + { + id: 200, + category_id: 30, + title: 'Shadow Bay', + rating: '7.5', + viewed_at: '2026-05-01T09:00:00.000Z', + poster_url: 'https://example.com/shadow-bay.png', + xtream_id: 4000, // series id + type: 'series', + playlist_id: 'xtream-C', + playlist_name: 'Xtream C', + }, + ]); + playlistsSignal.set([ + ...playlistsSignal(), + { + _id: 'xtream-C', + title: 'Xtream C', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://c.example.com', + }, + ]); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + (playlistId: string) => { + if (playlistId === 'xtream-C') { + return Promise.resolve([ + // Earlier episode — should NOT win even if it also + // matches the series, because the newer one is more + // relevant for "where you left off". + { + contentXtreamId: 4001, + contentType: 'episode', + seriesXtreamId: 4000, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 60, + durationSeconds: 1800, + playlistId, + updatedAt: '2026-04-25T12:00:00.000Z', + } as PlaybackPositionData, + { + contentXtreamId: 4007, + contentType: 'episode', + seriesXtreamId: 4000, + seasonNumber: 3, + episodeNumber: 7, + positionSeconds: 540, + durationSeconds: 1800, + playlistId, + updatedAt: '2026-05-01T09:30:00.000Z', + } as PlaybackPositionData, + ]); + } + return Promise.resolve([]); + } + ); + + await service.reloadGlobalRecentItems(); + await service.reloadPlaybackPositions(); + + const series = service + .globalRecentVodItems() + .find((item) => item.title === 'Shadow Bay'); + const position = service.getPlaybackPositionForItem(series!); + expect(position?.contentType).toBe('episode'); + expect(position?.seasonNumber).toBe(3); + expect(position?.episodeNumber).toBe(7); }); it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 7ba38114d..62cac219e 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -248,8 +248,6 @@ export class DashboardDataService { if (item.type !== 'movie' && item.type !== 'series') { return null; } - const positionType: 'vod' | 'episode' = - item.type === 'series' ? 'episode' : 'vod'; const xtreamId = typeof item.xtream_id === 'number' ? item.xtream_id @@ -257,12 +255,41 @@ export class DashboardDataService { if (!Number.isFinite(xtreamId)) { return null; } - const key = playbackPositionMapKey( - item.playlist_id, - xtreamId, - positionType - ); - return this.playbackPositionsMap().get(key) ?? null; + + if (item.type === 'movie') { + const key = playbackPositionMapKey( + item.playlist_id, + xtreamId, + 'vod' + ); + return this.playbackPositionsMap().get(key) ?? null; + } + + // Series recent_items rows carry either the series id (the series + // landing page writes the series itself) or, for direct-play flows, + // the episode id. Match both shapes and prefer the most recently + // updated episode position so the card can show resume progress and + // a "S2 · E5" badge regardless of how the row was originally saved. + let best: PlaybackPositionData | null = null; + for (const position of this.playbackPositionsMap().values()) { + if (position.contentType !== 'episode') continue; + if ( + position.playlistId && + position.playlistId !== item.playlist_id + ) { + continue; + } + const matchesEpisode = position.contentXtreamId === xtreamId; + const matchesSeries = position.seriesXtreamId === xtreamId; + if (!matchesEpisode && !matchesSeries) continue; + if ( + !best || + (position.updatedAt ?? '') > (best.updatedAt ?? '') + ) { + best = position; + } + } + return best; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 4a9c668c0..6ca18fa11 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -189,11 +189,18 @@ {{ card.title }} - @if (card.subtitle) { - {{ - card.subtitle - }} - } + + @if (card.episodeBadge) { + {{ + card.episodeBadge + }} + } + @if (card.subtitle) { + {{ + card.subtitle + }} + } + diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index efa43f327..b459f7d29 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -350,7 +350,34 @@ transition: color 0.15s ease; } +.rail__card-meta-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.rail__card-episode { + flex: 0 0 auto; + font-size: 0.66rem; + font-weight: 700; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + background: color-mix( + in srgb, + var(--app-selection-color, #2f86ff) 14%, + transparent + ); + color: var(--app-selection-color, #2f86ff); + letter-spacing: 0.02em; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + .rail__card-subtitle { + flex: 1 1 auto; + min-width: 0; font-size: 0.72rem; font-weight: 500; line-height: 1.4; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index a3d221740..16c1c6233 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -54,6 +54,13 @@ export interface DashboardRailCard { * position leave this unset. */ watchProgress?: number | null; + + /** + * Localised "S{n} · E{n}" badge for series with a tracked episode + * position. Renders as a small chip next to the card subtitle so the + * user can see which episode they were on without opening the show. + */ + episodeBadge?: string | null; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index ea2419443..2216b91da 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -615,6 +615,18 @@ export class WorkspaceDashboardRailsComponent { private toRecentCard(item: GlobalRecentItem): DashboardRailCard { const position = this.data.getPlaybackPositionForItem(item); const watchProgress = playbackProgressPercent(position); + const episodeBadge = + item.type === 'series' && + position?.seasonNumber != null && + position?.episodeNumber != null + ? this.translate.instant( + 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', + { + season: position.seasonNumber, + episode: position.episodeNumber, + } + ) + : null; return { id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`, title: item.title, @@ -625,6 +637,7 @@ export class WorkspaceDashboardRailsComponent { link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), watchProgress, + episodeBadge, }; }