feat(dashboard): show S·E badge on Continue Watching series cards

For series in the Continue Watching rail, surface which episode the user
left off on as a small "S2·E5" chip next to the card subtitle. Sources
the season/episode straight off the matched PlaybackPositionData so the
information is always in sync with the resume target.

Fixes a latent bug in the same patch: getPlaybackPositionForItem could
not resolve series whose recent_items row carried the series id (the
landing-page path), because playback_positions are keyed by the episode
id. The lookup now matches both shapes (contentXtreamId === xtreamId OR
seriesXtreamId === xtreamId) and prefers the most recently updated
episode, which also restores the resume progress bar that had been
silently dropping out for series.

Localised in all 17 non-English locales — most use language-specific
short forms (St·F in German, T·E in Spanish/Portuguese, С·Э in
Russian/Belarusian, 시즌N N화 in Korean) rather than the English S·E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 09:32:13 +02:00
1 parent 6891a1ca30
commit 8091dec074
24 files changed
+193 -13

No files matched your search

+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "مباشر",
"TYPE_MOVIE": "فيلم",
"TYPE_SERIES": "مسلسل",
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
"NOT_YET_SYNCED": "لم تتم المزامنة بعد"
},
"SHELL": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "مباشر",
"TYPE_MOVIE": "فيلم",
"TYPE_SERIES": "سلسلة",
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
"NOT_YET_SYNCED": "مازال ما تزامنش"
},
"SHELL": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "Прамы эфір",
"TYPE_MOVIE": "Фільм",
"TYPE_SERIES": "Серыял",
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана"
},
"SHELL": {
+1
View File
@@ -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": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "Ζωντανά",
"TYPE_MOVIE": "Ταινία",
"TYPE_SERIES": "Σειρά",
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα"
},
"SHELL": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "ライブ",
"TYPE_MOVIE": "映画",
"TYPE_SERIES": "シリーズ",
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
"NOT_YET_SYNCED": "未同期"
},
"SHELL": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "라이브",
"TYPE_MOVIE": "영화",
"TYPE_SERIES": "시리즈",
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
"NOT_YET_SYNCED": "아직 동기화되지 않음"
},
"SHELL": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "ТВ",
"TYPE_MOVIE": "Фильм",
"TYPE_SERIES": "Сериал",
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
"NOT_YET_SYNCED": "Еще не синхронизировано"
},
"SHELL": {
+1
View File
@@ -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": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "直播",
"TYPE_MOVIE": "电影",
"TYPE_SERIES": "剧集",
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
"NOT_YET_SYNCED": "尚未同步"
},
"SHELL": {
+1
View File
@@ -872,6 +872,7 @@
"TYPE_LIVE": "直播",
"TYPE_MOVIE": "電影",
"TYPE_SERIES": "影集",
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
"NOT_YET_SYNCED": "尚未同步"
},
"SHELL": {
@@ -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 () => {
@@ -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;
}
/**
@@ -189,11 +189,18 @@
<span class="rail__card-title">{{
card.title
}}</span>
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
<span class="rail__card-meta-row">
@if (card.episodeBadge) {
<span class="rail__card-episode">{{
card.episodeBadge
}}</span>
}
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
</span>
</div>
</a>
@@ -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;
@@ -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;
}
/**
@@ -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,
};
}