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,
};
}