mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
d75e656de5
commit
bb8251186b
2 files changed
+41
-16
No files matched your search
+18
-4
@@ -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,
|
||||
});
|
||||
|
||||
+23
-12
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user