fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items

Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-05 11:26:03 +02:00
1 parent d77a6a28cb
commit 0131b4ef95
2 files changed
+92 -1

No files matched your search

@@ -0,0 +1,83 @@
import { TestBed } from '@angular/core/testing';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
describe('DashboardHeroTmdbService', () => {
const tvDetails = {
id: 100,
backdrop_path: '/serial.jpg',
vote_average: 7.4,
vote_count: 12,
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
};
let isEnabled: jest.Mock;
let enrichMovie: jest.Mock;
let enrichTv: jest.Mock;
function createService(): DashboardHeroTmdbService {
TestBed.configureTestingModule({
providers: [
{
provide: TmdbEnrichmentService,
useValue: { isEnabled, enrichMovie, enrichTv },
},
],
});
return TestBed.inject(DashboardHeroTmdbService);
}
beforeEach(() => {
isEnabled = jest.fn().mockReturnValue(true);
enrichMovie = jest.fn().mockResolvedValue(null);
enrichTv = jest.fn().mockResolvedValue(tvDetails);
});
it('returns null when TMDB is disabled or the item is live', async () => {
isEnabled.mockReturnValue(false);
const service = createService();
await expect(
service.getExtras({ title: 'X', type: 'movie' })
).resolves.toBeNull();
isEnabled.mockReturnValue(true);
await expect(
service.getExtras({ title: 'X', type: 'live' })
).resolves.toBeNull();
expect(enrichMovie).not.toHaveBeenCalled();
});
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
// Stalker embedded-series items are typed 'movie' in activity rows
const service = createService();
const extras = await service.getExtras({
title: 'Между нами химия (8 серий)',
type: 'movie',
});
expect(enrichMovie).toHaveBeenCalledTimes(1);
expect(enrichTv).toHaveBeenCalledTimes(1);
expect(extras?.backdropUrl).toContain('/serial.jpg');
expect(extras?.rating).toBe('7.4');
expect(extras?.genres).toEqual(['Drama', 'Comedy']);
});
it('does not retry as TV when the movie lookup succeeds', async () => {
enrichMovie.mockResolvedValue({ ...tvDetails, id: 200 });
const service = createService();
await service.getExtras({ title: 'Heat', type: 'movie' });
expect(enrichTv).not.toHaveBeenCalled();
});
it('memoizes results per title and type', async () => {
const service = createService();
await service.getExtras({ title: 'The Boys', type: 'series' });
await service.getExtras({ title: 'The Boys', type: 'series' });
expect(enrichTv).toHaveBeenCalledTimes(1);
});
});
@@ -58,10 +58,18 @@ export class DashboardHeroTmdbService {
title: item.title,
year: extractYear(null, item.title),
};
const details =
let details =
item.type === 'movie'
? await this.enrichment.enrichMovie(query)
: await this.enrichment.enrichTv(query);
if (!details && item.type === 'movie') {
// Stalker embedded-series items (vclub) are typed 'movie'
// in activity rows but are TV shows on TMDB — the detail
// view resolves them via is_series, so the TV lookup is
// usually already cached. Misses are negative-cached, so
// the retry never turns into repeated network traffic.
details = await this.enrichment.enrichTv(query);
}
if (!details) {
return null;
}