feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

@@ -24,8 +24,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -27,6 +27,19 @@
</h3>
</div>
<div class="player-shell__actions">
@if (allSources().length > 1) {
<app-vod-sources-chip
class="player-shell__sources"
[sources]="allSources()"
[count]="alternativeSources().length"
[resumeLabel]="sourcesResumeLabel()"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(pinRequested)="sourcePinRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
/>
}
<button
mat-button
type="button"
@@ -68,6 +81,11 @@
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
[alternativeSources]="alternativeSources()"
(alternativeSourceRequested)="
alternativeSourceRequested.emit($event)
"
(playbackFailed)="playbackFailed.emit($event)"
(timeUpdate)="onTimeUpdate($event)"
(playbackEnded)="onPlaybackEnded()"
(previousEpisodeRequested)="onPreviousEpisodeRequested()"
@@ -75,6 +75,30 @@
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
align-items: center;
}
// The sources chip sits between a plain text button (Copy Stream URL) and an
// outlined one (Close player). It is a peer of the latter, so it borrows that
// shape — outlined pill, transparent fill, Material's 40px button height —
// rather than carrying the filled surface it wears in the detail hero.
.player-shell__actions ::ng-deep app-vod-sources-chip .sources-chip {
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--mat-sys-outline, rgb(255 255 255 / 24%));
border-radius: 20px;
background: transparent;
color: inherit;
font-size: 0.875rem;
font-weight: 500;
transition:
background-color 140ms ease,
border-color 140ms ease;
&:hover {
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 38%);
}
}
// The viewport is the "theater stage": it spans the full content width and,
@@ -25,8 +25,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -19,8 +19,10 @@ import {
PlayerContentInfo,
ResolvedPortalPlayback,
VideoPlayer,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
@@ -29,6 +31,7 @@ import type {
SeriesEpisodeMetadata,
SeriesPlaybackNavigation,
} from './series-playback-navigation';
import { VodSourcesChipComponent } from '@iptvnator/ui/components';
import { UpNextRailComponent } from './up-next-rail.component';
import type { UpNextRailItem } from './up-next-rail.util';
@@ -49,6 +52,7 @@ const RAIL_STAGE_GAP = 18;
MatTooltipModule,
TranslateModule,
UpNextRailComponent,
VodSourcesChipComponent,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -201,6 +205,17 @@ export class PortalInlinePlayerComponent {
}>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/** Alternatives offered on the playback-error screen. */
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Every source incl. the playing one — powers the in-player picker. */
readonly allSources = input<VodSourceDescriptor[]>([]);
/** Formatted resume timecode shown in the picker header. */
readonly sourcesResumeLabel = input<string | null>(null);
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly alternativeSourceRequested = output<string>();
/** Relayed playback failure, so hosts can offer/auto-pick a source. */
readonly playbackFailed = output<PlaybackDiagnosticCode>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -127,6 +127,36 @@
</div>
}
@if (alternativeSources().length > 0) {
<div
class="web-player-diagnostic__alternatives"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</h3>
@for (source of visibleAlternatives(); track source.id) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="alternativeSourceRequested.emit($event)"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: { count: hiddenAlternativeCount() }
}}
</p>
}
</div>
}
@if (canShowExternalFallbackActions()) {
<div
class="web-player-diagnostic__player-actions"
@@ -118,6 +118,41 @@ app-html-video-player {
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
@@ -23,9 +23,14 @@ import {
Settings,
STORE_KEY,
VideoPlayer,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
@@ -36,6 +41,7 @@ import {
} from '../player-controls';
import {
type PlaybackDiagnostic,
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
@@ -73,6 +79,7 @@ function resolveWebPlayerSharedControls(): boolean {
MatTooltipModule,
TranslatePipe,
VjsPlayerComponent,
VodSourceRowComponent,
],
providers: [
{
@@ -102,6 +109,31 @@ export class WebPlayerViewComponent {
duration: number;
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/**
* Alternative sources offered on the error screen, turning a dead end into
* a recovery point. Empty (the default) keeps the overlay exactly as it
* was for every non-multi-source host.
*/
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
readonly alternativeSourceRequested = output<string>();
/**
* A playback failure the host may be able to recover from by switching
* source. Emitted alongside showing the overlay, never instead of it: if
* the host does nothing, the user still sees the honest error.
*/
readonly playbackFailed = output<PlaybackDiagnosticCode>();
/**
* An error screen is a recovery point, not a catalogue. Offering fifty
* options here is worse than offering the best few — the full list stays
* one click away behind the player's own sources button.
*/
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
);
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -264,6 +296,9 @@ export class WebPlayerViewComponent {
}
this.playbackDiagnostic.set(issue);
if (issue) {
this.playbackFailed.emit(issue.code);
}
}
requestExternalFallback(player: ExternalPlayerName): void {