mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
7eaa681352
commit
a25412ea61
12 files changed
+761
-356
No files matched your search
@@ -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
|
||||
|
||||
+15
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+314
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user