fix(playlist): key M3U movie enrichment on the full lookup identity

The staleness guard compared channel ids while the request is built from the
title, which assumes an id determines the name. `createChannel` falls back to
the URL for a missing id, so two entries pointing at one stream can share one
— the second would then keep the first movie's metadata and skip its own
lookup. The guard now tracks id and title together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-13 09:55:55 +02:00
1 parent d75e656de5
commit bb8251186b
2 files changed
+41 -16

No files matched your search

@@ -45,7 +45,7 @@ describe('M3uVodMetadataService', () => {
await flush();
expect(service.state()).toEqual({
channelId: 'ch1',
lookupKey: 'ch1\u0000Dune (2021) 1080p',
status: 'matched',
details,
});
@@ -106,17 +106,31 @@ describe('M3uVodMetadataService', () => {
first.resolve({ id: 1, title: 'Dune' });
await flush();
expect(service.state().status).toBe('loading');
expect(service.state().channelId).toBe('ch2');
expect(service.state().lookupKey).toContain('ch2');
second.resolve({ id: 2, title: 'Alien' });
await flush();
expect(service.state()).toEqual({
channelId: 'ch2',
lookupKey: 'ch2\u0000Alien',
status: 'matched',
details: { id: 2, title: 'Alien' },
});
});
it('re-requests when a shared id carries a different title', async () => {
// `createChannel` falls back to the URL for a missing id, so two
// entries pointing at one stream can share one — the lookup identity
// is the id AND the title the request is actually built from.
enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' });
service.load({ id: 'same-id', name: 'Dune' });
await flush();
service.load({ id: 'same-id', name: 'Alien' });
expect(enrichMovie).toHaveBeenCalledTimes(2);
expect(service.state().status).toBe('loading');
});
it('does not re-request the channel the state already tracks', async () => {
enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' });
@@ -144,7 +158,7 @@ describe('M3uVodMetadataService', () => {
service.reset();
expect(service.state()).toEqual({
channelId: null,
lookupKey: null,
status: 'idle',
details: null,
});
@@ -5,18 +5,29 @@ import { Channel, releaseTagYear } from '@iptvnator/shared/interfaces';
export type M3uVodMetadataStatus = 'idle' | 'loading' | 'matched' | 'none';
export interface M3uVodMetadataState {
/** Channel the current state belongs to — staleness anchor */
channelId: string | null;
/**
* The lookup this state belongs to — the staleness anchor. Both the id
* AND the title, because the title is what is actually looked up: an id
* alone would assume it determines the name, and nothing guarantees
* that (`createChannel` falls back to the URL for a missing id, so two
* entries pointing at one stream would share it).
*/
lookupKey: string | null;
status: M3uVodMetadataStatus;
details: TmdbMovieDetails | null;
}
const IDLE_STATE: M3uVodMetadataState = {
channelId: null,
lookupKey: null,
status: 'idle',
details: null,
};
/** NUL keeps the two parts unambiguous whatever an entry is named. */
function lookupKeyOf(channel: Pick<Channel, 'id' | 'name'>): string {
return `${channel.id}\u0000${channel.name ?? ''}`;
}
/**
* TMDB lookup for an M3U entry recognized as a movie. Thin glue over
* {@link TmdbEnrichmentService}: the resolver already normalizes titles
@@ -39,17 +50,17 @@ export class M3uVodMetadataService {
* only the response for the channel the state currently tracks lands.
*/
load(channel: Pick<Channel, 'id' | 'name'>): void {
const channelId = channel.id;
if (this.stateSignal().channelId === channelId) {
const lookupKey = lookupKeyOf(channel);
if (this.stateSignal().lookupKey === lookupKey) {
return;
}
if (!this.tmdb.isEnabled()) {
this.stateSignal.set({ channelId, status: 'none', details: null });
this.stateSignal.set({ lookupKey, status: 'none', details: null });
return;
}
this.stateSignal.set({ channelId, status: 'loading', details: null });
this.stateSignal.set({ lookupKey, status: 'loading', details: null });
// `releaseTagYear` reads only bracketed/trailing release TAGS, never
// a year that is part of the film's name ("2001: A Space Odyssey").
@@ -59,8 +70,8 @@ export class M3uVodMetadataService {
year: releaseTagYear(channel.name),
})
.then(
(details) => this.settle(channelId, details),
() => this.settle(channelId, null)
(details) => this.settle(lookupKey, details),
() => this.settle(lookupKey, null)
);
}
@@ -68,13 +79,13 @@ export class M3uVodMetadataService {
this.stateSignal.set(IDLE_STATE);
}
private settle(channelId: string, details: TmdbMovieDetails | null): void {
if (this.stateSignal().channelId !== channelId) {
private settle(lookupKey: string, details: TmdbMovieDetails | null): void {
if (this.stateSignal().lookupKey !== lookupKey) {
return;
}
this.stateSignal.set({
channelId,
lookupKey,
status: details ? 'matched' : 'none',
details,
});