refactor(portals): lift the VOD route's orchestration out of the component

The details route had grown to 864 lines — the repository's hard maximum is
400, and while the file predates the rule, a baselined exemption is not a
budget to spend.

Three component-provided services now hold what the component was
accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch,
the caption, the primary button's position, source actions and the failover
toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and
`VodDetailsDownloadsService`. The component keeps its public API, so the
template and the existing specs are untouched. 864 -> 566 lines.

The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal`
read `route.snapshot.params`, which is stale once the router reuses this
component for detail-to-detail navigation (the Similar rail) — so a download
started from a film reached that way fetched the previous one. They now read
the same route-params signal everything else uses, with a regression test.

Also from review: pins are applied on the FRESH-import path too. A new
playlist has no content when the archive is read, so its user state is parked
and replayed after the import — the merge path I wired first never ran there,
and every pin was dropped. A failed pin write now propagates instead of being
ignored: the backup entry is reported failed, and the parked state is kept so
a transient failure can be retried rather than silently losing the preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 20:38:09 +02:00
1 parent 7eaa681352
commit a25412ea61
12 files changed
+761 -356

No files matched your search

+8
View File
@@ -478,6 +478,14 @@ Two things read it, and they must agree: the "Playing from" caption, and the
source row's badge — which reads `Current` when a source is merely selected and
`Playing` once one really is.
## Where the route's code lives
`VodDetailsRouteComponent` is a thin host. The multi-source concerns it grew
live in `VodDetailsMultiSourceUiService` (component-provided): the
`playbackLive` evidence, the caption, the primary button's position, the
source actions, and the failover toast. The "Similar" rail and offline
downloads sit in their own component-provided services beside it.
## Backup
Pins ride along with playlist backup, under the playlist they point at, as the
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
import {
DatabaseService,
PlaybackPositionService,
VodSourcePinService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import {
@@ -56,6 +57,13 @@ export function createDbServiceMock() {
};
}
export function createVodSourcePinServiceMock() {
return {
listForPlaylist: jest.fn().mockResolvedValue([]),
set: jest.fn().mockResolvedValue(true),
};
}
export function createPlaybackServiceMock() {
return {
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
@@ -85,6 +93,7 @@ export interface ElectronXtreamDataSourceHarness {
dataSource: ElectronXtreamDataSource;
dbService: ReturnType<typeof createDbServiceMock>;
playbackService: ReturnType<typeof createPlaybackServiceMock>;
vodSourcePinService: ReturnType<typeof createVodSourcePinServiceMock>;
pendingRestoreService: ReturnType<typeof createPendingRestoreServiceMock>;
apiService: ReturnType<typeof createApiServiceMock>;
}
@@ -92,6 +101,7 @@ export interface ElectronXtreamDataSourceHarness {
export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness {
const dbService = createDbServiceMock();
const playbackService = createPlaybackServiceMock();
const vodSourcePinService = createVodSourcePinServiceMock();
const pendingRestoreService = createPendingRestoreServiceMock();
const apiService = createApiServiceMock();
@@ -100,6 +110,10 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness
ElectronXtreamDataSource,
{ provide: DatabaseService, useValue: dbService },
{ provide: PlaybackPositionService, useValue: playbackService },
{
provide: VodSourcePinService,
useValue: vodSourcePinService,
},
{
provide: XtreamPendingRestoreService,
useValue: pendingRestoreService,
@@ -112,6 +126,7 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness
dataSource: TestBed.inject(ElectronXtreamDataSource),
dbService,
playbackService,
vodSourcePinService,
pendingRestoreService,
apiService,
};
@@ -4,6 +4,7 @@ import {
PlaybackPositionService,
XtreamPendingRestoreService,
XtreamImportStatus,
VodSourcePinService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
@@ -39,6 +40,7 @@ import {
export class ElectronXtreamDataSource implements IXtreamDataSource {
private readonly dbService = inject(DatabaseService);
private readonly playbackService = inject(PlaybackPositionService);
private readonly vodSourcePinService = inject(VodSourcePinService);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
@@ -540,9 +542,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async clearPlaylistContent(
playlistId: string
): Promise<XtreamPendingRestoreState> {
const [result, playbackPositions] = await Promise.all([
const [result, playbackPositions, sourcePins] = await Promise.all([
this.dbService.deleteXtreamPlaylistContent(playlistId),
this.playbackService.getAllPlaybackPositions(playlistId),
this.vodSourcePinService.listForPlaylist(playlistId),
]);
return {
@@ -550,6 +553,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
favorites: result.favorites,
recentlyViewed: result.recentlyViewed,
playbackPositions,
sourcePins: sourcePins.map((pin) => ({
matchKey: pin.matchKey,
contentId: pin.contentId,
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
})),
};
}
@@ -573,5 +581,27 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playbackPosition
);
}
// The fresh-import path lands here rather than in the backup service:
// a new playlist has no content yet when the archive is read, so its
// user state is parked and applied once the import finishes.
for (const pin of restoreState.sourcePins ?? []) {
const written = await this.vodSourcePinService.set({
matchKey: pin.matchKey,
playlistId,
contentId: pin.contentId,
portalType: 'xtream',
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
});
// Throwing keeps the pending state for a later retry — the caller
// only clears it when this resolves. Dropping it here would lose
// the preference with the import still reporting success.
if (!written) {
throw new Error(
`Restoring the pinned source for "${pin.matchKey}" failed.`
);
}
}
}
}
@@ -194,9 +194,57 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
favorites,
recentlyViewed,
playbackPositions: [position],
sourcePins: [],
});
});
it('applies parked source pins against the imported playlist', async () => {
// The fresh-import path: a new playlist has no content when the
// archive is read, so its user state is parked and replayed here.
// Without this the backup's pins are dropped for every new import.
const restoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [
{
matchKey: 'tmdb:603',
contentId: 501,
updatedAt: '2026-07-06T09:00:00.000Z',
},
],
} as never;
await harness.dataSource.restoreUserData(playlistId, restoreState);
expect(harness.vodSourcePinService.set).toHaveBeenCalledWith({
matchKey: 'tmdb:603',
playlistId,
contentId: 501,
portalType: 'xtream',
updatedAt: '2026-07-06T09:00:00.000Z',
});
});
it('keeps the pending state when a pin cannot be written', async () => {
harness.vodSourcePinService.set.mockResolvedValue(false);
const restoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }],
} as never;
// The caller clears the parked state only when this resolves, so
// resolving here would drop the preference on a transient DB
// failure while the import still reported success.
await expect(
harness.dataSource.restoreUserData(playlistId, restoreState)
).rejects.toThrow('tmdb:603');
});
it('restores user data, then resets and replays playback positions', async () => {
const positionA = { contentXtreamId: 1 } as never;
const positionB = { contentXtreamId: 2 } as never;
@@ -0,0 +1,126 @@
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService } from '@iptvnator/services';
import {
XtreamVodDetails,
getXtreamVodInfo,
} from '@iptvnator/shared/interfaces';
/**
* Offline downloads for the movie on screen.
*
* Every read re-touches `downloads()` so the computed tracks the signal the
* service updates — the per-item helpers are plain lookups and would not
* otherwise re-run when a download's state changes.
*/
@Injectable()
export class VodDetailsDownloadsService {
private readonly downloadsService = inject(DownloadsService);
private readonly xtreamStore = inject(XtreamStore);
private routeContentId: Signal<number> = signal(NaN);
bind(bindings: { routeContentId: Signal<number> }): void {
this.routeContentId = bindings.routeContentId;
}
private readonly context = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Read so the state below re-derives as downloads progress.
this.downloadsService.downloads();
return playlistId ? { playlistId, vodId: this.routeContentId() } : null;
});
readonly isDownloaded = computed(() => {
const context = this.context();
return context
? this.downloadsService.isDownloaded(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
readonly isDownloading = computed(() => {
const context = this.context();
return context
? this.downloadsService.isDownloading(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
readonly isPausedDownload = computed(() => {
const context = this.context();
return context
? this.downloadsService.isPaused(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
async resumePaused(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
await this.downloadsService.resumeDownloadByContent(
this.routeContentId(),
playlistId,
'vod'
);
}
async start(vodItem: XtreamVodDetails | null): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
if (!vodItem || !playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
const routeVodId = this.routeContentId();
const id = Number.isFinite(routeVodId)
? routeVodId
: Number(
vodItem.movie_data?.stream_id ||
(vodItem as { stream_id?: number }).stream_id
);
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: id,
contentType: 'vod',
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
url: this.xtreamStore.constructVodStreamUrl(vodItem),
posterUrl: info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
});
}
async playLocal(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const filePath = this.downloadsService.getDownloadedFilePath(
this.routeContentId(),
playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -0,0 +1,314 @@
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SettingsStore } from '@iptvnator/services';
import {
PlaybackPositionData,
PlayerContentInfo,
playlistDisplayLabel,
reportsPlaybackFailures,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import {
createPrimaryActionPosition,
formatPlaybackPosition,
} from './vod-primary-action-position';
/**
* The VOD details route's multi-source concerns.
*
* Everything here answers one of two questions: what is TRUE about playback
* right now, and what does the primary button act on. Both are easy to get
* subtly wrong — the controller's "active source" means selected rather than
* playing, and positions are keyed per (playlist, stream) so the row the page
* loaded is not necessarily the one a click will start.
*
* Component-provided, like the host service whose session it reads.
*/
@Injectable()
export class VodDetailsMultiSourceUiService {
private readonly multiSource = inject(VodMultiSourceHostService);
private readonly playback = inject(VodDetailsPlaybackService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
/** The vod id the route is showing; bound once by the host component. */
private routeContentId: Signal<number> = signal(NaN);
/** The movie identity, for the sources popover header. */
private movieTitle: Signal<string> = signal('');
bind(bindings: {
routeContentId: Signal<number>;
movieTitle: Signal<string>;
}): void {
this.routeContentId = bindings.routeContentId;
this.movieTitle = bindings.movieTitle;
}
/** True from a playback failure until something plays again. */
private readonly playbackFailed = signal(false);
/**
* Cleared on every start; set by the first timeupdate.
*
* `inlinePlayback()` is the REQUEST to play — it is non-null while the
* engine is still opening the stream, and stays non-null after it fails.
* A timeupdate is the engine reporting that it is producing frames, which
* is the only evidence the page has that something is really playing.
*/
private readonly inlineTimeSeen = signal(false);
/**
* Whether a stream is on screen right now.
*
* Both the "Playing from" caption and the row's Playing badge are claims
* about the present, and discovery marks a source active the moment the
* page opens — long before anything plays, and again after the player is
* closed. Gating both on this keeps them from lying.
*/
readonly playbackLive = computed(() => {
if (this.playback.inlinePlayback()) {
return !this.playbackFailed() && this.inlineTimeSeen();
}
// The external player is a window of its own: once it is open the film
// is on screen, and only 'launching' means it is not there yet.
const status = this.playback.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
/**
* The source playing when it is NOT the route's own.
*
* Both halves of the comparison matter: a pinned copy can live in the
* route's OWN playlist, and matching on playlist alone would call it "the
* route source" — losing the Stop state and every position update for it.
*/
readonly activeAlternativeSource = computed<PlayerContentInfo | null>(
() => {
const active = this.multiSource
.sources()
.find((source) => source.isActive);
const routePlaylistId = this.xtreamStore.currentPlaylist()?.id;
if (
!active ||
(active.playlistId === routePlaylistId &&
active.contentId === this.routeContentId())
) {
return null;
}
return {
playlistId: active.playlistId,
contentXtreamId: active.contentId,
contentType: 'vod' as const,
};
}
);
private readonly primaryAction = createPrimaryActionPosition({
sources: this.multiSource.sources,
routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
routeContentId: computed(() => this.routeContentId()),
routePosition: this.playback.routePlaybackPosition,
livePosition: this.playback.vodPlaybackPosition,
load: (source) => this.positionFor(source),
});
readonly hasPlaybackPosition = this.primaryAction.hasPosition;
formatPosition(): string {
return formatPlaybackPosition(this.primaryAction.position());
}
/**
* Where a specific source was last watched.
*
* Positions are keyed by (playlist, stream), so a pinned alternative has
* its own row — the one this page loaded belongs to the route's copy.
*/
readonly positionFor = (
source: VodSourceCandidate | VodSourceDescriptor
): Promise<PlaybackPositionData | null> =>
this.playbackPositions.getPlaybackPosition(
source.playlistId,
source.contentId,
'vod'
);
readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> =>
(await this.positionFor(source))?.positionSeconds ?? null;
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(() => this.movieTitle());
/** The ".srcbar" caption under the action row: where this is playing from. */
readonly activeSourceCaption = computed(() => {
const active = this.multiSource
.sources()
.find((source) => source.isActive);
// "Playing from" only while something actually is. Discovery marks a
// source active as soon as the page opens, so gating on that alone
// makes the line a claim about a player that has not started, one the
// user has since closed, or one that failed and is showing an error.
if (!active || !this.playbackLive()) {
return null;
}
// Only FACTS reach the caption. A guessed quality would read as a
// claim about the stream the user is watching right now.
const facts = [
active.quality?.provenance !== 'parsed'
? active.quality?.value
: null,
active.container?.provenance !== 'parsed'
? active.container?.value
: null,
].filter(Boolean);
return {
// Never the raw playlist name: it is routinely the pasted URL,
// credentials and all, and this line sits in the open on every
// screenshot of the detail page.
source: [
playlistDisplayLabel(active.playlistName, active.playlistId),
...facts,
].join(' · '),
// The caption speaks of PLAYLISTS ("also found in 2 others"), so
// three copies inside one portal must not read as three portals.
alternativeCount: this.multiSource.alternativePlaylistCount(),
};
});
/**
* Only the built-in web players raise a playback diagnostic, so on MPV,
* VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The
* toggle is hidden there rather than left promising a switch that cannot
* happen.
*/
readonly autoFailoverSupported = computed(() =>
reportsPlaybackFailures(this.settingsStore.player?.())
);
/** A new stream is starting: nothing yet vouches for it. */
beginPlayback(): void {
this.playbackFailed.set(false);
this.inlineTimeSeen.set(false);
}
/** Route reuse — the previous movie's evidence must not carry over. */
reset(): void {
this.inlineTimeSeen.set(false);
}
playFromSource(sourceId: string): void {
// Only once the switch actually starts something. A source picked off
// the error screen that cannot be resolved leaves the diagnostic up,
// and clearing eagerly would have the caption claim playback again.
void this.multiSource.play(sourceId).then((switched) => {
if (switched) {
this.playbackFailed.set(false);
}
});
}
pinSource(sourceId: string): void {
void this.multiSource.togglePin(sourceId);
}
checkSource(sourceId: string): void {
void this.multiSource.check(sourceId);
}
setAutoFailover(enabled: boolean): void {
// `updateSettings` patches memory first and REJECTS if the write
// fails, so without this the toggle looks saved, reverts on restart,
// and the rejection surfaces only as an unhandled promise.
this.settingsStore
.updateSettings({ vodAutoFailover: enabled })
.catch(() =>
this.snackBar.open(
this.translateService.instant(
'SETTINGS.SETTINGS_SAVE_FAILED'
),
this.translateService.instant('CLOSE'),
{ duration: 10000 }
)
);
}
/**
* A source failed. With auto-failover on we move to the best untried
* source and ANNOUNCE it; otherwise the player's own error overlay — which
* is already showing the alternatives — is left to do its job.
*/
async onPlaybackFailed(): Promise<void> {
// The error screen is up: nothing is playing from anywhere until a
// source actually starts again.
this.playbackFailed.set(true);
const notice = await this.multiSource.failover();
if (!notice) {
return;
}
this.snackBar
.open(
this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCHED_TO',
{ playlist: notice.playlistName }
) +
(notice.audioMayDiffer
? ` — ${this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING'
)}`
: ''),
this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'),
{ duration: 10000 }
)
.onAction()
.subscribe(() => this.undoFailover());
}
/** Return to the source that was playing before the automatic switch. */
private undoFailover(): void {
const previousId = this.multiSource.previousSourceId();
if (previousId) {
void this.multiSource.play(previousId);
}
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
// The engine is producing time, so whatever failed before is over.
this.playbackFailed.set(false);
this.inlineTimeSeen.set(true);
const settled = this.playback.handleInlineTimeUpdate(event);
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
//
// Until the engine has finished seeking to the resume point it reports
// ~0, and that is not where the film is — it is where it has not got
// to yet. Feeding it to multi-source would make a switch or a failure
// during those first seconds restart the movie from the beginning, so
// the position we asked the engine for stands in until it arrives.
this.multiSource.reportPosition(
settled
? event.currentTime
: (this.playback.inlinePlayback()?.startTime ?? 0)
);
}
}
@@ -163,4 +163,21 @@ describe('VodDetailsRouteComponent', () => {
).componentInstance as ContentHeroComponent;
expect(hero.backdropUrl()).toBeUndefined();
});
it('downloads the movie the route currently shows', async () => {
currentPlaylist.set({ id: 'playlist-1' });
fixture.detectChanges();
await fixture.componentInstance.downloadVod({
movie_data: { stream_id: 111, name: 'Example' },
} as never);
// The id comes from the route params SIGNAL, not `snapshot.params`:
// the router reuses this component for detail-to-detail navigation
// (the Similar rail), and the snapshot still names the film the user
// came from — so the download would fetch the wrong movie.
expect(stubs.startDownload).toHaveBeenCalledWith(
expect.objectContaining({ xtreamId: 650020 })
);
});
});
@@ -62,6 +62,9 @@ import {
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service';
import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import {
@@ -76,7 +79,13 @@ import {
'./vod-details-route.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService, VodMultiSourceHostService],
providers: [
VodDetailsPlaybackService,
VodMultiSourceHostService,
VodDetailsMultiSourceUiService,
VodDetailsSimilarService,
VodDetailsDownloadsService,
],
imports: [
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -104,6 +113,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly msUi = inject(VodDetailsMultiSourceUiService);
private readonly similar = inject(VodDetailsSimilarService);
private readonly downloads = inject(VodDetailsDownloadsService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
@@ -236,130 +248,41 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.externalPrimaryButtonState;
readonly vodPlaybackProgress = this.playback.vodPlaybackProgress;
/**
* What the primary button acts on — the PINNED copy's position when a pin
* names another copy, since that is the stream the button will play.
*/
private readonly primaryAction = createPrimaryActionPosition({
sources: this.multiSource.sources,
routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
routeContentId: this.selectedVodId,
routePosition: this.playback.routePlaybackPosition,
livePosition: this.playback.vodPlaybackPosition,
load: (source) => this.positionFor(source),
});
readonly hasPlaybackPosition = this.primaryAction.hasPosition;
readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition;
readonly isDownloaded = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(vodId, playlistId, 'vod');
});
readonly isDownloading = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
});
readonly isPausedDownload = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isPaused(vodId, playlistId, 'vod');
});
readonly isDownloaded = this.downloads.isDownloaded;
readonly isDownloading = this.downloads.isDownloading;
readonly isPausedDownload = this.downloads.isPausedDownload;
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
/** TMDB recommendations matched against the loaded VOD catalog */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.selectedVodInfo();
if (!info?.tmdb_recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.xtreamStore.vodStreams(),
{ excludeId: this.selectedVodId() }
);
});
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.selectedVodInfo()?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie', {
excludePlaylistId: playlistId,
})
.then((items) => {
if (
this.selectedVodInfo()?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
/**
* The alternative the player is on, in playback's terms — null while the
* route's own source is playing, which the matcher already recognises.
*/
private readonly activeAlternativeSource = computed(() => {
const active = this.multiSource.sources().find((s) => s.isActive);
const routePlaylistId = this.xtreamStore.currentPlaylist()?.id;
// 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;
}
return {
playlistId: active.playlistId,
contentXtreamId: active.contentId,
contentType: 'vod' as const,
};
});
constructor() {
this.downloads.bind({ routeContentId: this.selectedVodId });
this.similar.bind({
vodInfo: this.selectedVodInfo,
routeContentId: this.selectedVodId,
});
this.msUi.bind({
routeContentId: this.selectedVodId,
movieTitle: computed(() => this.multiSourceMovie()?.title ?? ''),
});
this.playback.bind({
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
activeSource: this.activeAlternativeSource,
activeSource: this.msUi.activeAlternativeSource,
});
effect(() => {
@@ -391,7 +314,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// evidence that the previous one was playing says nothing
// about this one — without clearing it the caption and the
// badge would claim the new source while it is still opening.
this.inlineTimeSeen.set(false);
this.msUi.reset();
this.playback.startResolvedPlayback(playback);
},
movie: this.multiSourceMovie,
@@ -416,7 +339,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// lookup cannot repopulate either one.
this.vodPlaybackPosition.set(null);
this.playback.routePlaybackPosition.set(null);
this.inlineTimeSeen.set(false);
this.msUi.reset();
this.initializeVodDetails(playlistId, vodId);
});
@@ -492,15 +415,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// timeupdate would otherwise resolve the next source back at it.
this.multiSource.reportPosition(0);
this.multiSource.markRouteSourceActive();
this.playbackFailed.set(false);
this.inlineTimeSeen.set(false);
this.msUi.beginPlayback();
this.playback.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.playbackFailed.set(false);
this.inlineTimeSeen.set(false);
this.msUi.beginPlayback();
// The controller can still hold an ALTERNATIVE's timecode. A failure
// before the first timeupdate would otherwise resolve the next source
// at a position that belongs to a different copy.
@@ -522,7 +443,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// A pinned source is an explicit "play this movie from here", so it
// outranks the playlist the route happens to be on. Falls through to
// the normal path when nothing is pinned or the pin cannot resolve.
if (await this.multiSource.playPinnedSource(this.resumeSecondsFor)) {
if (
await this.multiSource.playPinnedSource(this.msUi.resumeSecondsFor)
) {
return;
}
@@ -537,32 +460,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playVod(vodItem);
}
/**
* Where a specific source was last watched.
*
* Positions are keyed by (playlist, stream), so a pinned alternative has
* its own row — the one this page loaded belongs to the route's copy.
*/
private readonly positionFor = (
source: VodSourceCandidate | VodSourceDescriptor
): Promise<PlaybackPositionData | null> =>
this.playbackPositions.getPlaybackPosition(
source.playlistId,
source.contentId,
'vod'
);
private readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> =>
(await this.positionFor(source))?.positionSeconds ?? null;
stopExternalPlayback(): Promise<void> {
return this.playback.stopExternalPlayback();
}
formatPosition(): string {
return formatPlaybackPosition(this.primaryAction.position());
return this.msUi.formatPosition();
}
toggleFavorite(): void {
@@ -592,187 +496,38 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.closeInlinePlayer();
}
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(
() => this.multiSourceMovie()?.title ?? ''
);
/** The ".srcbar" caption under the action row: where this is playing from. */
readonly activeSourceCaption = computed(() => {
const active = this.multiSource.sources().find((s) => s.isActive);
// "Playing from" only while something actually is. Discovery marks a
// source active as soon as the page opens, so gating on that alone
// makes the line a claim about a player that has not started, one the
// user has since closed, or one that failed and is showing an error.
if (!active || !this.playbackLive()) {
return null;
}
// Only FACTS reach the caption. A guessed quality would read as a
// claim about the stream the user is watching right now.
const facts = [
active.quality?.provenance !== 'parsed'
? active.quality?.value
: null,
active.container?.provenance !== 'parsed'
? active.container?.value
: null,
].filter(Boolean);
return {
// Never the raw playlist name: it is routinely the pasted URL,
// credentials and all, and this line sits in the open on every
// screenshot of the detail page.
source: [
playlistDisplayLabel(active.playlistName, active.playlistId),
...facts,
].join(' · '),
// The caption speaks of PLAYLISTS ("also found in 2 others"), so
// three copies inside one portal must not read as three portals.
alternativeCount: this.multiSource.alternativePlaylistCount(),
};
});
readonly multiSourceTitle = this.msUi.multiSourceTitle;
readonly activeSourceCaption = this.msUi.activeSourceCaption;
playFromSource(sourceId: string): void {
// Only once the switch actually starts something. A source picked off
// the error screen that cannot be resolved leaves the diagnostic up,
// and clearing eagerly would have the caption claim playback again.
void this.multiSource.play(sourceId).then((switched) => {
if (switched) {
this.playbackFailed.set(false);
}
});
this.msUi.playFromSource(sourceId);
}
pinSource(sourceId: string): void {
void this.multiSource.togglePin(sourceId);
this.msUi.pinSource(sourceId);
}
checkSource(sourceId: string): void {
void this.multiSource.check(sourceId);
this.msUi.checkSource(sourceId);
}
/**
* Only the built-in web players raise a playback diagnostic, so on MPV,
* VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The
* toggle is hidden there rather than left promising a switch that cannot
* happen.
*/
readonly autoFailoverSupported = computed(() =>
reportsPlaybackFailures(this.settingsStore.player?.())
);
readonly autoFailoverSupported = this.msUi.autoFailoverSupported;
setAutoFailover(enabled: boolean): void {
// `updateSettings` patches memory first and REJECTS if the write
// fails, so without this the toggle looks saved, reverts on restart,
// and the rejection surfaces only as an unhandled promise.
this.settingsStore
.updateSettings({ vodAutoFailover: enabled })
.catch(() =>
this.snackBar.open(
this.translateService.instant(
'SETTINGS.SETTINGS_SAVE_FAILED'
),
this.translateService.instant('CLOSE'),
{ duration: 10000 }
)
);
this.msUi.setAutoFailover(enabled);
}
/**
* A source failed. With auto-failover on we move to the best untried
* source and ANNOUNCE it; otherwise the player's own error overlay — which
* is already showing the alternatives — is left to do its job.
*/
async onPlaybackFailed(): Promise<void> {
// The error screen is up: nothing is playing from anywhere until a
// source actually starts again.
this.playbackFailed.set(true);
const notice = await this.multiSource.failover();
if (!notice) {
return;
}
this.snackBar
.open(
this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCHED_TO',
{ playlist: notice.playlistName }
) +
(notice.audioMayDiffer
? ` — ${this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING'
)}`
: ''),
this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'),
{ duration: 10000 }
)
.onAction()
.subscribe(() => this.undoFailover());
onPlaybackFailed(): Promise<void> {
return this.msUi.onPlaybackFailed();
}
/** Return to the source that was playing before the automatic switch. */
private undoFailover(): void {
const previousId = this.multiSource.previousSourceId();
if (previousId) {
void this.multiSource.play(previousId);
}
}
/** True from a playback failure until something plays again. */
private readonly playbackFailed = signal(false);
/**
* Cleared on every start; set by the first timeupdate.
*
* `inlinePlayback()` is the REQUEST to play — it is non-null while the
* engine is still opening the stream, and stays non-null after it fails.
* A timeupdate is the engine reporting that it is producing frames, which
* is the only evidence the page has that something is really playing.
*/
private readonly inlineTimeSeen = signal(false);
/**
* Whether a stream is on screen right now.
*
* Both the "Playing from" caption and the row's Playing badge are claims
* about the present, and discovery marks a source active the moment the
* page opens — long before anything plays, and again after the player is
* closed. Gating both on this keeps them from lying.
*/
readonly playbackLive = computed(() => {
if (this.inlinePlayback()) {
return !this.playbackFailed() && this.inlineTimeSeen();
}
// The external player is a window of its own: once it is open the film
// is on screen, and only 'launching' means it is not there yet.
const status = this.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
readonly playbackLive = this.msUi.playbackLive;
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
// The engine is producing time, so whatever failed before is over.
this.playbackFailed.set(false);
this.inlineTimeSeen.set(true);
const settled = this.playback.handleInlineTimeUpdate(event);
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
//
// Until the engine has finished seeking to the resume point it reports
// ~0, and that is not where the film is — it is where it has not got
// to yet. Feeding it to multi-source would make a switch or a failure
// during those first seconds restart the movie from the beginning, so
// the position we asked the engine for stands in until it arrives.
this.multiSource.reportPosition(
settled
? event.currentTime
: (this.inlinePlayback()?.startTime ?? 0)
);
this.msUi.handleInlineTimeUpdate(event);
}
showCopyNotification(): void {
@@ -789,67 +544,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.handleExternalFallbackRequest(request);
}
async resumePausedDownload(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
await this.downloadsService.resumeDownloadByContent(
this.selectedVodId(),
playlistId,
'vod'
);
resumePausedDownload(): Promise<void> {
return this.downloads.resumePaused();
}
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (!vodItem) {
return;
}
const info = getXtreamVodInfo(vodItem);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.route.snapshot.params.vodId;
const id = routeVodId
? Number(routeVodId)
: Number(
vodItem.movie_data?.stream_id ||
(vodItem as { stream_id?: number }).stream_id
);
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: id,
contentType: 'vod',
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
url: streamUrl,
posterUrl: info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
});
downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
return this.downloads.start(vodItem);
}
async playFromLocal(): Promise<void> {
const vodId = Number(this.route.snapshot.params.vodId);
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return;
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
playFromLocal(): Promise<void> {
return this.downloads.playLocal();
}
private initializeVodDetails(playlistId: string, vodId: number): void {
@@ -60,6 +60,7 @@ export function createVodDetailsRouteStubs() {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
snackBarOpen: jest.fn(),
startDownload: jest.fn().mockResolvedValue(undefined),
locationBack: jest.fn(),
selectedPlayer: signal<VideoPlayer>(VideoPlayer.Html5Player),
updateSettings: jest.fn().mockResolvedValue(undefined),
@@ -190,7 +191,7 @@ export async function configureVodDetailsRouteTestBed(
downloads: stubs.downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
startDownload: jest.fn(),
startDownload: stubs.startDownload,
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
},
@@ -0,0 +1,108 @@
import {
Injectable,
Signal,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import {
normalizeTitleKeys,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
/**
* The "Similar" rail on the VOD details page.
*
* Two sources feed it: TMDB recommendations matched against the catalog this
* playlist already loaded, and — in Electron — the same recommendations
* matched across every OTHER imported playlist. The second list is filtered
* against the first so one film does not appear twice under different portals.
*
* Component-provided: the cross-portal lookup is per-movie and its result must
* die with the page rather than leak into the next film.
*/
@Injectable()
export class VodDetailsSimilarService {
private readonly xtreamStore = inject(XtreamStore);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private vodInfo: Signal<XtreamVodInfo | null> = signal(null);
private routeContentId: Signal<number> = signal(NaN);
bind(bindings: {
vodInfo: Signal<XtreamVodInfo | null>;
routeContentId: Signal<number>;
}): void {
this.vodInfo = bindings.vodInfo;
this.routeContentId = bindings.routeContentId;
}
/** TMDB recommendations matched against the loaded VOD catalog. */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.vodInfo();
if (!info?.tmdb_recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.xtreamStore.vodStreams(),
{ excludeId: this.routeContentId() }
);
});
/** Recommendations found in the user's OTHER portals (Electron only). */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.vodInfo()?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie', {
excludePlaylistId: playlistId,
})
.then((items) => {
// The user can open another film across the lookup, and
// the rail must not describe the one they left.
if (
this.vodInfo()?.tmdb_recommendations === recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
}
@@ -867,13 +867,22 @@ export class PlaylistBackupService {
// The match key identifies the film and survives untouched; only the
// playlist has a new id in this installation.
for (const pin of state.sourcePins ?? []) {
await this.vodSourcePinService.set({
const written = await this.vodSourcePinService.set({
matchKey: pin.matchKey,
playlistId,
contentId: pin.contentId,
portalType: 'xtream',
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
});
// `set` reports a failed write rather than throwing, so ignoring
// it would drop the preference while the summary claims the
// import succeeded.
if (!written) {
throw new PlaylistBackupError(
`Restoring the pinned source for "${pin.matchKey}" failed.`
);
}
}
}
@@ -254,6 +254,31 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
});
});
it('reports a pin that could not be written', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
// `set` reports failure rather than throwing, so ignoring the
// result would drop the preference while the summary claims
// the import succeeded.
set: jest.fn().mockResolvedValue(false),
},
});
const manifest = createXtreamManifest(
[],
[{ matchKey: 'tmdb:603', contentId: 501 }]
);
const summary = await service.importBackup(JSON.stringify(manifest));
expect(summary).toEqual(
expect.objectContaining({ merged: 0, failed: 1 })
);
});
it('imports an archive written before pins existed', async () => {
const collaborators = createRestoreCollaborators();
const setPin = jest.fn().mockResolvedValue(true);