mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(portals): keep a pinned play, a same-playlist copy and Check honest
Five findings from the latest review pass. Reading a pinned source's own position is a database round-trip, and the user can navigate across it — the continuation then handed one film's source id to whichever movie now owned the screen. Guarded, like every other await here. Allowing a pinned copy to live in the current playlist made "is this the route's own source?" a two-part question, and the ownership check still asked only about the playlist: an external session for that copy was disowned, so Stop vanished and its progress was dropped. The yearless title alias is shared by every remake, so clearing every alias before a write could delete a different film's pin. Writes and unpins now touch only keys that name one film — plus the ambiguous row this session actually read, which is the one the user is looking at and the one whose absence would make an unpin come back. Restart left the seeded position in the controller, so a failure before the first timeupdate resolved the next source back at it. And the alternative rows on the playback-error screen had a Check button wired to nothing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0c99fac87
commit
ce1b1f3269
13 files changed
+295
-40
No files matched your search
@@ -825,7 +825,7 @@ engine` (restart required) or
|
||||
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height.
|
||||
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
|
||||
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
|
||||
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
|
||||
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
|
||||
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only; it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
|
||||
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
|
||||
- See `docs/architecture/vod-multi-source.md`
|
||||
|
||||
@@ -131,8 +131,17 @@ pinned under any poorer form of itself:
|
||||
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first.
|
||||
Reading every alias is what keeps a late TMDB id from orphaning an earlier pin.
|
||||
|
||||
A write **clears every alias and then stores only the canonical key** — both
|
||||
halves matter, and each rules out the other's shortcut:
|
||||
Three key sets, because reading, writing and deleting are different questions
|
||||
(`pinKeysFor` builds all three so they cannot disagree):
|
||||
|
||||
| set | contents | why |
|
||||
|---|---|---|
|
||||
| `lookup` | every alias above | a pin may sit under any poorer form of the movie |
|
||||
| `write` | `tmdb:` + `title:{base}:{year}` | keys that name exactly ONE film |
|
||||
| `loaded` | the key the pin on screen came from | the only ambiguous row this session may retire |
|
||||
|
||||
A write stores the canonical key and clears the stale ones — both halves
|
||||
matter, and each rules out the other's shortcut:
|
||||
|
||||
- Writing only the top key would leave the lower-trust aliases pointing at
|
||||
whatever was pinned before, and a reopen that reads one of those (enrichment
|
||||
@@ -140,8 +149,12 @@ halves matter, and each rules out the other's shortcut:
|
||||
- Writing the decision *into* every alias is not the fix either: the yearless
|
||||
form is shared by every remake, so a known-year pin stored there would answer
|
||||
for a different film — pin Dune (2021), open Dune (1984) before its year
|
||||
arrives, and it starts the 2021 source. That alias stays readable and
|
||||
unwritten.
|
||||
arrives, and it starts the 2021 source.
|
||||
|
||||
For the same reason a write or an unpin never *deletes* the yearless alias on
|
||||
spec: that row may hold another remake's preference. The single exception is
|
||||
the row this session actually read, because the user is acting on the pin they
|
||||
can see — and leaving that one would make an unpin come back.
|
||||
|
||||
The row only changes once the write lands. A pin the database refused is worse
|
||||
than no pin at all — the icon promises the preference will be there next time,
|
||||
|
||||
@@ -211,6 +211,61 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
consoleWarnSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('owns an external session for a copy in its own playlist', () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
const component = fixture.componentInstance;
|
||||
const playback = fixture.debugElement.injector.get(
|
||||
VodDetailsPlaybackService
|
||||
);
|
||||
|
||||
// A pinned copy can now live in the route's OWN playlist. Comparing
|
||||
// playlists alone would call this "the route source", and the page
|
||||
// would disown the session it started for it.
|
||||
Object.defineProperty(component.multiSource, 'sources', {
|
||||
configurable: true,
|
||||
value: () => [
|
||||
{
|
||||
id: 'playlist-1:xtream:4242',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Portal One',
|
||||
portalType: 'xtream',
|
||||
contentId: 4242,
|
||||
rawTitle: 'Example',
|
||||
matchConfidence: 'exact',
|
||||
year: null,
|
||||
isActive: true,
|
||||
isPinned: true,
|
||||
isTried: true,
|
||||
probe: { status: 'idle' },
|
||||
},
|
||||
],
|
||||
});
|
||||
activeSession.set({
|
||||
player: 'mpv',
|
||||
status: 'playing',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 4242,
|
||||
contentType: 'vod',
|
||||
},
|
||||
});
|
||||
|
||||
expect(playback.matchedExternalPlayback()).not.toBeNull();
|
||||
expect(component.isExternalStopAction()).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the carried position when Restart starts from the beginning', () => {
|
||||
const component = fixture.componentInstance;
|
||||
const reported = jest.spyOn(component.multiSource, 'reportPosition');
|
||||
|
||||
component.playVod({} as XtreamVodDetails);
|
||||
|
||||
// The controller still holds whatever this page was seeded with, and
|
||||
// a failure before the first timeupdate would resolve the next source
|
||||
// back at it instead of honouring the restart.
|
||||
expect(reported).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('stops the external player when the button says Stop', async () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
activeSession.set({
|
||||
|
||||
@@ -317,7 +317,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private readonly activeAlternativeSource = computed(() => {
|
||||
const active = this.multiSource.sources().find((s) => s.isActive);
|
||||
const routePlaylistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!active || active.playlistId === routePlaylistId) {
|
||||
// Both halves: a pinned copy can now live in the route's OWN playlist,
|
||||
// and comparing the playlist alone would call it "the route source" —
|
||||
// losing the Stop state and every position update for it.
|
||||
if (
|
||||
!active ||
|
||||
(active.playlistId === routePlaylistId &&
|
||||
active.contentId === this.selectedVodId())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -447,6 +454,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
playVod(vodItem: XtreamVodDetails | null): void {
|
||||
// Restart means from the beginning. The controller still holds the
|
||||
// position this page was seeded with, and a failure before the first
|
||||
// timeupdate would otherwise resolve the next source back at it.
|
||||
this.multiSource.reportPosition(0);
|
||||
this.playback.playVod(vodItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,29 +206,49 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('retires every stale alias and writes only the canonical key', async () => {
|
||||
it('retires its own aliases but never another remake’s', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
const matchKeys: string[] = pins.get.mock.calls[0][0];
|
||||
expect(matchKeys.length).toBeGreaterThan(1);
|
||||
const lookupKeys: string[] = pins.get.mock.calls[0][0];
|
||||
const yearless = lookupKeys[lookupKeys.length - 1];
|
||||
expect(yearless).toBe('title:the matrix:');
|
||||
|
||||
await service.togglePin(ALT_TWO.id);
|
||||
|
||||
// Leaving the other aliases alone would keep them pointing at whatever
|
||||
// was pinned before, and a reopen that reads one — because enrichment
|
||||
// has not landed yet — starts the source the user just replaced.
|
||||
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
|
||||
// But the decision is NOT written back into them: `title:{base}:` is
|
||||
// shared by every remake, so a known-year pin stored there would
|
||||
// answer for a different film.
|
||||
// Stale aliases of THIS movie go, or a reopen before enrichment reads
|
||||
// one and starts the source the user just replaced.
|
||||
const [retired] = pins.clear.mock.calls[0] as [string[]];
|
||||
expect(retired).toContain('title:the matrix:1999');
|
||||
// The yearless form is shared by every remake: a Dune (2021) pin must
|
||||
// not delete — or answer for — a row that may be Dune (1984)'s.
|
||||
expect(retired).not.toContain(yearless);
|
||||
|
||||
expect(pins.set).toHaveBeenCalledTimes(1);
|
||||
expect(pins.set).toHaveBeenCalledWith({
|
||||
matchKey: matchKeys[0],
|
||||
matchKey: 'title:the matrix:1999',
|
||||
playlistId: ALT_TWO.playlistId,
|
||||
contentId: ALT_TWO.contentId,
|
||||
portalType: 'xtream',
|
||||
});
|
||||
});
|
||||
|
||||
it('retires the ambiguous row it actually read', async () => {
|
||||
// A pin set before the year was known lives under the yearless key.
|
||||
// This session read it, so the user is unpinning THAT row — leaving it
|
||||
// would make the unpin come back on the next open.
|
||||
pins.get.mockResolvedValue({
|
||||
matchKey: 'title:the matrix:',
|
||||
playlistId: ALT_TWO.playlistId,
|
||||
contentId: ALT_TWO.contentId,
|
||||
portalType: 'xtream',
|
||||
});
|
||||
await loadMovie([ALT_TWO]);
|
||||
|
||||
await service.togglePin(ALT_TWO.id);
|
||||
|
||||
const [retired] = pins.clear.mock.calls[0] as [string[]];
|
||||
expect(retired).toContain('title:the matrix:');
|
||||
});
|
||||
|
||||
it('does not show a pin the database refused to store', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
pins.set.mockResolvedValue(false);
|
||||
@@ -321,7 +341,7 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
|
||||
await service.togglePin(ALT_TWO.id);
|
||||
|
||||
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
|
||||
expect(pins.clear).toHaveBeenCalled();
|
||||
// Unpinning writes nothing further.
|
||||
expect(pins.set).toHaveBeenCalledTimes(1);
|
||||
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
|
||||
|
||||
@@ -256,6 +256,39 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
|
||||
expect(service.busySourceId()).toBeNull();
|
||||
});
|
||||
|
||||
it('abandons a pinned play whose movie was left during the lookup', async () => {
|
||||
pins.get.mockResolvedValue({
|
||||
matchKey: 'title:the matrix:1999',
|
||||
playlistId: ALT_TWO.playlistId,
|
||||
contentId: ALT_TWO.contentId,
|
||||
portalType: 'xtream',
|
||||
});
|
||||
await loadMovie([ALT_TWO]);
|
||||
|
||||
const lookup = createDeferred<number | null>();
|
||||
let insideLookup = false;
|
||||
const playing = service.playPinnedSource(() => {
|
||||
insideLookup = true;
|
||||
return lookup.promise;
|
||||
});
|
||||
// Wait until the call is genuinely inside the position lookup —
|
||||
// stopping earlier would only re-prove the guard before it.
|
||||
while (!insideLookup) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Reading that source's own resume position is a database round-trip,
|
||||
// and another movie can take the screen across it. Playing then would
|
||||
// hand THIS film's source id to the film now showing.
|
||||
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B);
|
||||
startPlayback.mockClear();
|
||||
|
||||
lookup.resolve(3600);
|
||||
|
||||
await expect(playing).resolves.toBe(false);
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a pin change whose movie was navigated away from', async () => {
|
||||
const pinFor = (candidate: VodSourceCandidate) => ({
|
||||
matchKey: 'title:the matrix:1999',
|
||||
|
||||
@@ -35,11 +35,12 @@ import {
|
||||
pinnedSourceAwaitingPlay,
|
||||
pinnedSourceIdOf,
|
||||
playPinned,
|
||||
pinKeysFor,
|
||||
readPin,
|
||||
togglePinnedSource,
|
||||
type PinKeySets,
|
||||
} from './vod-multi-source-pin';
|
||||
import {
|
||||
buildVodSourceMatchKeyCandidates,
|
||||
type ResolvedPortalPlayback,
|
||||
type VodSourceCandidate,
|
||||
type VodSourceDescriptor,
|
||||
@@ -73,18 +74,13 @@ export class VodMultiSourceHostService {
|
||||
|
||||
private controller = new VodMultiSourceController();
|
||||
private bindings: VodMultiSourceBindings | null = null;
|
||||
private matchKeys: string[] = [];
|
||||
private pinKeys: PinKeySets = { lookup: [], write: [] };
|
||||
/** Bumped per `load()`: discards a discovery the next one superseded. */
|
||||
private discoveryToken = 0;
|
||||
/**
|
||||
* Bumped when the FILM changes, and only then — a rediscovery of the same
|
||||
* film must not cancel a switch or probe in flight for it.
|
||||
*/
|
||||
/** Bumped when the FILM changes — a rediscovery of the same one is a
|
||||
* refresh, and must not cancel a switch or probe in flight for it. */
|
||||
private sessionToken = 0;
|
||||
/**
|
||||
* Bumped by every switch, so a slower resolution cannot overwrite the
|
||||
* user's latest choice, nor the source Undo points back to.
|
||||
*/
|
||||
/** Bumped by every switch: a slower one must not overwrite a newer. */
|
||||
private switchToken = 0;
|
||||
private lastMovieKey: string | null = null;
|
||||
private movieIdentity: string | null = null;
|
||||
@@ -205,14 +201,14 @@ export class VodMultiSourceHostService {
|
||||
this._busySourceId.set(null);
|
||||
this._previousSourceId.set(null);
|
||||
}
|
||||
this.matchKeys = buildVodSourceMatchKeyCandidates(movie);
|
||||
this.pinKeys = pinKeysFor(movie);
|
||||
|
||||
if (!this.discovery.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read FIRST, because discovery has to be told which copy to keep.
|
||||
const pin = await readPin(this.pins, this.matchKeys);
|
||||
const pin = await readPin(this.pins, this.pinKeys.lookup);
|
||||
if (token !== this.discoveryToken) {
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +231,9 @@ export class VodMultiSourceHostService {
|
||||
);
|
||||
if (pin) {
|
||||
this.controller.setPinnedSource(pinnedSourceIdOf(pin));
|
||||
// Remember where it was found: that row is the one the user sees,
|
||||
// so it is the only ambiguous key an unpin may remove.
|
||||
this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey };
|
||||
}
|
||||
|
||||
this.publish();
|
||||
@@ -258,10 +257,12 @@ export class VodMultiSourceHostService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = this.sessionToken;
|
||||
return playPinned({
|
||||
controller: this.controller,
|
||||
pinnedSourceId: this.pendingPinnedSourceId(),
|
||||
resumeFor,
|
||||
isCurrent: () => session === this.sessionToken,
|
||||
play: (sourceId) => this.play(sourceId),
|
||||
});
|
||||
}
|
||||
@@ -306,7 +307,7 @@ export class VodMultiSourceHostService {
|
||||
);
|
||||
const pinned = await togglePinnedSource(
|
||||
this.pins,
|
||||
this.matchKeys,
|
||||
this.pinKeys,
|
||||
this.controller.findSource(sourceId),
|
||||
isPinned
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-acc
|
||||
import type { VodSourcePinService } from '@iptvnator/services';
|
||||
import {
|
||||
buildVodSourceMatchKey,
|
||||
buildVodSourceMatchKeyCandidates,
|
||||
buildVodSourceMatchKeyWriteKeys,
|
||||
type VodSourceCandidate,
|
||||
type VodSourceDescriptor,
|
||||
type VodSourcePin,
|
||||
@@ -74,15 +76,15 @@ export async function readPin(
|
||||
*/
|
||||
export async function writePin(
|
||||
pins: Pick<VodSourcePinService, 'set' | 'clear'>,
|
||||
matchKeys: readonly string[],
|
||||
keys: PinKeySets,
|
||||
candidate: VodSourceCandidate
|
||||
): Promise<boolean> {
|
||||
const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate);
|
||||
const matchKey = keys.write[0] ?? buildVodSourceMatchKey(candidate);
|
||||
if (!matchKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await erasePin(pins, matchKeys);
|
||||
await erasePin(pins, retirablePinKeys(keys));
|
||||
|
||||
// The write can fail — no bridge, or the DB refused it. Reporting success
|
||||
// then would show a pin the next visit does not have.
|
||||
@@ -113,25 +115,63 @@ export async function erasePin(
|
||||
*/
|
||||
export async function togglePinnedSource(
|
||||
pins: Pick<VodSourcePinService, 'set' | 'clear'>,
|
||||
matchKeys: readonly string[],
|
||||
keys: PinKeySets,
|
||||
candidate: VodSourceCandidate | null,
|
||||
isPinned: boolean
|
||||
): Promise<string | null | undefined> {
|
||||
if (matchKeys.length === 0) {
|
||||
if (keys.write.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isPinned) {
|
||||
return (await erasePin(pins, matchKeys)) ? null : undefined;
|
||||
return (await erasePin(pins, retirablePinKeys(keys)))
|
||||
? null
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (await writePin(pins, matchKeys, candidate))
|
||||
? candidate.id
|
||||
: undefined;
|
||||
return (await writePin(pins, keys, candidate)) ? candidate.id : undefined;
|
||||
}
|
||||
|
||||
/** Which keys a pin may be looked up under, stored under, and removed from. */
|
||||
export interface PinKeySets {
|
||||
/** Every alias the pin may be sitting under, most-trusted first. */
|
||||
lookup: readonly string[];
|
||||
/** Keys that name exactly one film. */
|
||||
write: readonly string[];
|
||||
/** The key the pin currently on screen was actually found under. */
|
||||
loaded?: string | null;
|
||||
}
|
||||
|
||||
/** The three key sets for a movie, in one place so they cannot disagree. */
|
||||
export function pinKeysFor(movie: {
|
||||
tmdbId?: number | string | null;
|
||||
title?: string | null;
|
||||
year?: number | null;
|
||||
}): PinKeySets {
|
||||
return {
|
||||
lookup: buildVodSourceMatchKeyCandidates(movie),
|
||||
write: buildVodSourceMatchKeyWriteKeys(movie),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a write or an unpin is allowed to remove.
|
||||
*
|
||||
* Never the ambiguous yearless alias on spec — it may hold another remake's
|
||||
* preference. The one exception is the row this session actually READ: the
|
||||
* user is acting on the pin they can see, whatever key it happens to live
|
||||
* under, and leaving it would make an unpin come back.
|
||||
*/
|
||||
function retirablePinKeys(keys: PinKeySets): string[] {
|
||||
const retire = [...keys.write];
|
||||
if (keys.loaded && !retire.includes(keys.loaded)) {
|
||||
retire.push(keys.loaded);
|
||||
}
|
||||
return retire;
|
||||
}
|
||||
|
||||
/** What starting the pinned source needs from the host. */
|
||||
@@ -140,6 +180,8 @@ export interface PinnedPlayDeps {
|
||||
pinnedSourceId: string | null;
|
||||
/** Where THAT source was last watched, when the host can look it up. */
|
||||
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>;
|
||||
/** False once another movie owns the screen — the lookup is awaited. */
|
||||
isCurrent: () => boolean;
|
||||
play: (sourceId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -159,6 +201,13 @@ export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
|
||||
}
|
||||
|
||||
const stored = await deps.resumeFor?.(pinned);
|
||||
// That lookup is a database round-trip, and the user can navigate across
|
||||
// it. Playing then would hand THIS film's source id to whatever movie now
|
||||
// owns the screen — starting it, if the id happens to exist there.
|
||||
if (!deps.isCurrent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stored !== null && stored !== undefined) {
|
||||
deps.controller.setResumeSeconds(stored);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,36 @@ export function buildVodSourceMatchKeyCandidates(input: {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys that name exactly ONE film — the only ones safe to write to or
|
||||
* delete.
|
||||
*
|
||||
* `buildVodSourceMatchKeyCandidates` deliberately also offers the yearless
|
||||
* `title:{base}:` form, because a pin set before the release year was known
|
||||
* lives there. But that form is shared by every remake: storing a
|
||||
* known-year decision in it would answer for a different film, and deleting
|
||||
* it could throw away a different film's preference.
|
||||
*/
|
||||
export function buildVodSourceMatchKeyWriteKeys(input: {
|
||||
tmdbId?: number | string | null;
|
||||
title?: string | null;
|
||||
year?: number | null;
|
||||
}): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
const tmdbId = normalizeTmdbId(input.tmdbId);
|
||||
if (tmdbId !== null) {
|
||||
keys.push(`${TMDB_PREFIX}${tmdbId}`);
|
||||
}
|
||||
|
||||
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
|
||||
if (titleKey && !keys.includes(titleKey)) {
|
||||
keys.push(titleKey);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function isTmdbMatchKey(key: string): boolean {
|
||||
return key.startsWith(TMDB_PREFIX);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
(alternativeSourceRequested)="
|
||||
alternativeSourceRequested.emit($event)
|
||||
"
|
||||
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
|
||||
(playbackFailed)="playbackFailed.emit($event)"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
(playbackEnded)="onPlaybackEnded()"
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
[source]="source"
|
||||
[showPin]="false"
|
||||
(playRequested)="alternativeSourceRequested.emit($event)"
|
||||
(checkRequested)="sourceCheckRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (hiddenAlternativeCount() > 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
@@ -139,6 +140,9 @@ describe('WebPlayerViewComponent', () => {
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
// Real, not stubbed: the point of the test below is
|
||||
// that this row's Check action reaches the host.
|
||||
VodSourceRowComponent,
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -210,6 +214,41 @@ describe('WebPlayerViewComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('relays the alternative row’s Check action out of the error screen', () => {
|
||||
const checked: string[] = [];
|
||||
component.sourceCheckRequested.subscribe((id) => checked.push(id));
|
||||
fixture.componentRef.setInput('alternativeSources', [
|
||||
{
|
||||
id: 'playlist-2:xtream:991',
|
||||
playlistId: 'playlist-2',
|
||||
playlistName: 'Portal Two',
|
||||
portalType: 'xtream',
|
||||
contentId: 991,
|
||||
rawTitle: 'Example Movie',
|
||||
matchConfidence: 'exact',
|
||||
year: null,
|
||||
isActive: false,
|
||||
isPinned: false,
|
||||
isTried: false,
|
||||
probe: { status: 'idle' },
|
||||
},
|
||||
]);
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const check = fixture.debugElement.query(
|
||||
By.css('app-vod-source-row .source-tag--action')
|
||||
);
|
||||
expect(check).not.toBeNull();
|
||||
check.nativeElement.click();
|
||||
|
||||
// Without the relay the button is visibly actionable and inert: the
|
||||
// row emits into an output nobody observes, so no probe ever runs.
|
||||
expect(checked).toEqual(['playlist-2:xtream:991']);
|
||||
});
|
||||
|
||||
it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => {
|
||||
const requests: unknown[] = [];
|
||||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||||
|
||||
@@ -116,6 +116,8 @@ export class WebPlayerViewComponent {
|
||||
*/
|
||||
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
/** The row's Check action, which has to reach the host that can probe. */
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
/**
|
||||
* A playback failure the host may be able to recover from by switching
|
||||
* source. Emitted alongside showing the overlay, never instead of it: if
|
||||
|
||||
Reference in new issue
Block a user