fix(portals): write the pin before retiring it, and keep the badge honest

Three findings from the latest review pass.

Repinning cleared the old rows and then wrote the new one, so a write that
failed after the clear left nothing persisted while the row still showed the
old pin. The order is reversed: the new key is stored first and the stale
ones retired only once it landed. Lookups are most-trusted-first, so a
leftover alias never outranks what was just written.

Starting a source from the picker, or letting a pin decide the primary Play,
never recorded the movie as recently viewed — unlike every other way of
playing it.

And closing an alternative's player and pressing Play started the route
stream while the controller still marked the alternative active, so the
picker and caption named a source that was not running.

Moves the discovery pass into the session module beside the switch and
failover mechanics, and splits the pin spec along the persistence/playback
seam, both to stay inside the file-size rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 04:08:46 +02:00
1 parent decde93f60
commit 11e4e89d66
9 files changed
+374 -158

No files matched your search

+12
View File
@@ -151,6 +151,11 @@ matter, and each rules out the other's shortcut:
for a different film — pin Dune (2021), open Dune (1984) before its year
arrives, and it starts the 2021 source.
A write stores the new key **before** retiring the old rows. The other order
destroys the stored preference and can then fail to replace it, leaving nothing
persisted while the row still shows the old pin; lookups are most-trusted-first,
so a leftover alias never outranks the key just written.
The pin a rediscovery reads is applied **immediately**, not after its source
lookup returns: holding that snapshot across the await lets it overwrite a pin
the user makes in the meantime, leaving the row and the primary Play naming a
@@ -355,6 +360,13 @@ a page that shows a Stop button for a session whose progress it discards keeps
the resume point at wherever playback began, so a switch an hour later rewinds
the whole session.
Whichever source ends up playing, the "playing" badge follows it: starting the
route's own stream (Play, Resume, Restart, or the fallback after a pin does not
apply) hands the badge back to the route row, or the picker and caption go on
naming an alternative that is no longer running. And a source started through
the picker or a pin is recorded in Recently Viewed exactly as an ordinary Play
is — it is the same film, watched.
Stop then has to win over the pin. The primary action consults the pin first —
that is what makes "make this the main source" decide where playback starts —
but when a session is already running the same button reads Stop, and doing
@@ -27,6 +27,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const activeSource = signal<PlayerContentInfo | null>(null);
@@ -46,6 +47,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
addRecentItem.mockClear();
TestBed.configureTestingModule({
providers: [
@@ -54,7 +56,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
addRecentItem: jest.fn(),
addRecentItem,
},
},
{
@@ -139,6 +141,23 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('records a source started through multi-source as recently viewed', () => {
// Playing an alternative from the picker, or letting a pin decide the
// primary Play, is still watching the movie — it belongs in Recently
// Viewed exactly as an ordinary Play does.
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
expect(addRecentItem).toHaveBeenCalled();
});
describe('position updates from the bridge', () => {
function emit(playlistId: string, contentXtreamId: number, at: number) {
positionListener?.({
@@ -85,10 +85,9 @@ export class VodDetailsPlaybackService {
* Whether this page owns the content an external session or a position
* update refers to.
*
* Multi-source can put playback on a movie in ANOTHER playlist, and its
* session ids and position rows then carry that playlist's identity. One
* predicate for both consumers: when they disagree, the page shows a Stop
* button for a session whose progress it is throwing away.
* Multi-source can put playback on a movie in ANOTHER playlist, whose ids
* its session and position rows then carry. One predicate for both: when
* they disagree the page shows Stop for a session whose progress it drops.
*/
private ownsContent(
info:
@@ -174,10 +173,8 @@ export class VodDetailsPlaybackService {
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
// An external player running an ALTERNATIVE reports under
// that playlist's ids. Dropping those updates would leave
// the resume point at wherever playback started, and a
// later switch would rewind the whole session.
// An external player on an ALTERNATIVE reports under
// that playlist's ids; dropping those rewinds a switch.
if (this.ownsContent(data)) {
this.vodPlaybackPosition.set(data);
}
@@ -383,6 +380,9 @@ export class VodDetailsPlaybackService {
* engine survive and simply re-seek to `playback.startTime`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
// Same movie, different source: still a view, and the picker and
// pin paths would otherwise leave it out of Recently Viewed.
this.addToRecentlyViewed();
this.startPlayback(playback);
}
@@ -458,10 +458,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// position this page was seeded with, and a failure before the first
// timeupdate would otherwise resolve the next source back at it.
this.multiSource.reportPosition(0);
this.multiSource.markRouteSourceActive();
this.playback.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.playback.resumeVod(vodItem);
}
@@ -481,6 +483,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return;
}
// Falling through to the route's own stream: whatever alternative was
// last active is not what will be playing.
this.multiSource.markRouteSourceActive();
this.playback.onPrimaryAction(vodItem);
}
@@ -0,0 +1,210 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
PROBE_OK,
createDeferred,
resolveWith,
} from './vod-multi-source-host.fixtures';
/**
* What the database ends up holding.
*
* The row on screen is a promise that the preference survives reopening the
* movie, so a write that did not land must not be shown as one — and the
* aliases a movie can be looked up by are not all safe to write to or delete.
*/
describe('VodMultiSourceHostService — pin persistence', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie })
);
});
it('lets a pin made during a rediscovery win over the old one', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
// Enrichment reruns discovery for the same film...
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
const reloading = service.load({ ...MOVIE_A, tmdbId: 603 });
while (discovery.discover.mock.calls.length < 2) {
await Promise.resolve();
}
// ...and the user pins a different source while it is still out.
await service.togglePin(ALT_THREE.id);
slow.resolve({
sources: [ALT_TWO, ALT_THREE],
matchKind: 'title-year',
});
await reloading;
// The snapshot that rerun started with is now stale. Restoring it
// would leave the row and Play on a source the database no longer
// holds.
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('retires its own aliases but never another remake’s', async () => {
// With a TMDB id there are two keys naming this film and one shared
// with every remake, so the retire set is worth asserting on.
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
expect(pins.get.mock.calls[0][0]).toEqual([
'tmdb:603',
'title:the matrix:1999',
'title:the matrix:',
]);
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(
expect.objectContaining({ matchKey: 'tmdb:603' })
);
const [retired] = pins.clear.mock.calls[0] as [string[]];
// This film's other key goes, so a reopen before enrichment cannot
// read a row still pointing at the source just replaced.
expect(retired).toContain('title:the matrix:1999');
// The yearless form is shared by every remake: a Dune (2021) pin must
// not delete — or answer for — a row that may be Dune (1984)'s.
expect(retired).not.toContain('title:the matrix:');
// And never the row just written.
expect(retired).not.toContain('tmdb:603');
});
it('keeps the stored pin when the replacement write fails', async () => {
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// Retiring first would destroy the stored preference and then fail to
// replace it: nothing persisted, while the row still shows the old pin.
expect(pins.clear).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('retires the ambiguous row it actually read', async () => {
// A pin set before the year was known lives under the yearless key.
// This session read it, so the user is unpinning THAT row — leaving it
// would make the unpin come back on the next open.
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
await service.togglePin(ALT_TWO.id);
const [retired] = pins.clear.mock.calls[0] as [string[]];
expect(retired).toContain('title:the matrix:');
});
it('does not show a pin the database refused to store', async () => {
await loadMovie([ALT_TWO]);
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// The icon promises the preference survives reopening the movie. A
// write that failed makes that a lie, so the row must not change.
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('keeps the pin when clearing it fails', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
pins.clear.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
});
});
@@ -206,111 +206,18 @@ describe('VodMultiSourceHostService — pinning', () => {
);
});
it('lets a pin made during a rediscovery win over the old one', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
// Enrichment reruns discovery for the same film...
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
const reloading = service.load({ ...MOVIE_A, tmdbId: 603 });
while (discovery.discover.mock.calls.length < 2) {
await Promise.resolve();
}
// ...and the user pins a different source while it is still out.
await service.togglePin(ALT_THREE.id);
slow.resolve({
sources: [ALT_TWO, ALT_THREE],
matchKind: 'title-year',
});
await reloading;
// The snapshot that rerun started with is now stale. Restoring it
// would leave the row and Play on a source the database no longer
// holds.
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('retires its own aliases but never another remake’s', async () => {
it('hands the playing badge back when the route stream starts', async () => {
await loadMovie([ALT_TWO]);
const lookupKeys: string[] = pins.get.mock.calls[0][0];
const yearless = lookupKeys[lookupKeys.length - 1];
expect(yearless).toBe('title:the matrix:');
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
await service.togglePin(ALT_TWO.id);
// The user closed that player and pressed Play. The route's own stream
// is what runs now, so the picker and caption must not keep naming the
// alternative as "Playing".
service.markRouteSourceActive();
// Stale aliases of THIS movie go, or a reopen before enrichment reads
// one and starts the source the user just replaced.
const [retired] = pins.clear.mock.calls[0] as [string[]];
expect(retired).toContain('title:the matrix:1999');
// The yearless form is shared by every remake: a Dune (2021) pin must
// not delete — or answer for — a row that may be Dune (1984)'s.
expect(retired).not.toContain(yearless);
expect(pins.set).toHaveBeenCalledTimes(1);
expect(pins.set).toHaveBeenCalledWith({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
});
it('retires the ambiguous row it actually read', async () => {
// A pin set before the year was known lives under the yearless key.
// This session read it, so the user is unpinning THAT row — leaving it
// would make the unpin come back on the next open.
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
await service.togglePin(ALT_TWO.id);
const [retired] = pins.clear.mock.calls[0] as [string[]];
expect(retired).toContain('title:the matrix:');
});
it('does not show a pin the database refused to store', async () => {
await loadMovie([ALT_TWO]);
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// The icon promises the preference survives reopening the movie. A
// write that failed makes that a lie, so the row must not change.
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('keeps the pin when clearing it fails', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
pins.clear.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('waits for the stored pin before letting Play fall through', async () => {
@@ -22,7 +22,7 @@ import {
type VodMultiSourceMovie,
} from './vod-multi-source-identity';
import {
applyDiscoveredSources,
runDiscovery,
runFailover,
switchToSource,
type SwitchOutcome,
@@ -31,7 +31,6 @@ import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice';
import { currentSourceRow } from './vod-multi-source-current-row';
import { probeSource } from './vod-multi-source-probe';
import {
pinnedCopyInPlaylist,
pinnedSourceAwaitingPlay,
pinnedSourceIdOf,
playPinned,
@@ -84,6 +83,8 @@ export class VodMultiSourceHostService {
private switchToken = 0;
private lastMovieKey: string | null = null;
private movieIdentity: string | null = null;
/** The row standing for the playlist the route is on. */
private routeSourceId: string | null = null;
/** Resolves once the discovery on the way has published its sources. */
private loadInFlight: Promise<void> | null = null;
@@ -114,9 +115,8 @@ export class VodMultiSourceHostService {
/** Alternative STREAMS — what the "Sources N" chip counts. */
readonly alternativeCount = computed(() => this.alternatives().length);
/**
* Alternative PLAYLISTS. The popover groups one playlist's three copies
* under that playlist, so "also found in N other playlists" must count
* portals, not copies, or it contradicts the list it opens.
* Alternative PLAYLISTS: the popover groups a playlist's copies under it,
* so "also found in N other playlists" counts portals, not copies.
*/
readonly alternativePlaylistCount = computed(
() => new Set(this.alternatives().map((s) => s.playlistId)).size
@@ -168,12 +168,10 @@ export class VodMultiSourceHostService {
*
* A DIFFERENT film starts a fresh session — the router REUSES this
* component for detail→detail navigation, so everything resets, above all
* the tried-source set that makes failover terminate.
*
* The SAME film arriving again is a refresh, not a new session: enrichment
* changes the movie key on purpose, but nothing about what is playing.
* Resetting there would take the film off the source the user switched to
* and hand failover a clean tried-set for sources it has already burned.
* the tried-source set that makes failover terminate. The SAME film
* arriving again is a refresh: enrichment changes the movie key on
* purpose, but nothing about what is playing, and resetting would take the
* film off the source the user switched to.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const finished = this.discover(movie);
@@ -207,39 +205,29 @@ export class VodMultiSourceHostService {
return;
}
// Read FIRST, because discovery has to be told which copy to keep.
const pin = await readPin(this.pins, this.pinKeys.lookup);
if (token !== this.discoveryToken) {
return;
}
const routeSource = currentSourceRow(movie);
this.routeSourceId = routeSource.id;
// Applied NOW, not after the discovery below: holding this snapshot
// across that await lets it overwrite a pin made in the meantime, and
// the row would name a source the database no longer holds. `loaded`
// records where it was found — the only ambiguous key an unpin may
// remove, being the row the user can actually see.
if (pin) {
this.controller.setPinnedSource(pinnedSourceIdOf(pin));
this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey };
}
const result = await this.discovery.discover({
title: movie.title,
year: movie.year,
currentPlaylistId: movie.playlistId,
keepContentId: pinnedCopyInPlaylist(pin, movie.playlistId),
await runDiscovery({
controller: this.controller,
isCurrent: () => token === this.discoveryToken,
readPin: () => readPin(this.pins, this.pinKeys.lookup),
applyPin: (pin) => {
this.controller.setPinnedSource(pinnedSourceIdOf(pin));
// Where it was found is the only ambiguous key an unpin may
// remove, being the row the user can actually see.
this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey };
},
discover: (keepContentId) =>
this.discovery.discover({
title: movie.title,
year: movie.year,
currentPlaylistId: movie.playlistId,
keepContentId,
}),
routeSource,
publish: () => this.publish(),
});
if (token !== this.discoveryToken) {
return;
}
applyDiscoveredSources(
this.controller,
currentSourceRow(movie),
result.sources,
result.matchKind
);
this.publish();
}
/** The pinned source, when it is not the one the route already plays. */
@@ -364,6 +352,18 @@ export class VodMultiSourceHostService {
return switched ? this._lastSwitch() : null;
}
/**
* Hand the "playing" badge back to the route's own row: closing an
* alternative and pressing Play starts the route stream, and the picker
* and caption must stop naming a source that is not running.
*/
markRouteSourceActive(): void {
if (this.routeSourceId) {
this.controller.setActiveSource(this.routeSourceId);
this.publish();
}
}
/** The live position, fed ahead of the persist throttle. */
reportPosition(seconds: number): void {
this.controller.setResumeSeconds(seconds);
@@ -84,16 +84,26 @@ export async function writePin(
return false;
}
await erasePin(pins, retirablePinKeys(keys));
// The write can fail — no bridge, or the DB refused it. Reporting success
// then would show a pin the next visit does not have.
return pins.set({
// The write goes FIRST, and the old rows only go once it landed. The
// other order destroys the stored preference and then fails to replace it
// — leaving nothing persisted while the UI still shows the old pin.
// Lookups are most-trusted-first, so a leftover alias never outranks the
// key just written.
const stored = await pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
});
if (!stored) {
return false;
}
await erasePin(
pins,
retirablePinKeys(keys).filter((key) => key !== matchKey)
);
return true;
}
/** Clears every alias, so unpinning is not undone by a stale row. */
@@ -3,11 +3,13 @@ import type {
ResolvedPortalPlayback,
VodSourceCandidate,
VodSourceMatchKind,
VodSourcePin,
} from '@iptvnator/shared/interfaces';
import {
buildSwitchNotice,
type VodMultiSourceSwitchNotice,
} from './vod-multi-source-notice';
import { pinnedCopyInPlaylist } from './vod-multi-source-pin';
/**
* Session mechanics for one open movie: how a discovery folds into the state
@@ -178,3 +180,54 @@ export async function runFailover(
// is strictly a different source and the loop terminates.
}
}
/** What one discovery pass needs from the host, without reaching into it. */
export interface DiscoveryRunDeps {
controller: VodMultiSourceController;
/** False once a newer `load()` superseded this one. */
isCurrent: () => boolean;
readPin: () => Promise<VodSourcePin | null>;
/** Applied the moment it is read — see below. */
applyPin: (pin: VodSourcePin) => void;
discover: (keepContentId: number | null) => Promise<{
sources: VodSourceCandidate[];
matchKind: VodSourceMatchKind;
}>;
routeSource: VodSourceCandidate;
publish: () => void;
}
/**
* One discovery pass, from the pin lookup to the published source list.
*
* The pin is read first because discovery has to be told which copy of the
* film to keep, and applied immediately rather than after the source lookup:
* holding that snapshot across the await lets it overwrite a pin the user
* makes in the meantime, leaving the row naming a source the database no
* longer holds. Applying it first makes the later write simply win.
*/
export async function runDiscovery(deps: DiscoveryRunDeps): Promise<void> {
const pin = await deps.readPin();
if (!deps.isCurrent()) {
return;
}
if (pin) {
deps.applyPin(pin);
}
const result = await deps.discover(
pinnedCopyInPlaylist(pin, deps.routeSource.playlistId)
);
if (!deps.isCurrent()) {
return;
}
applyDiscoveredSources(
deps.controller,
deps.routeSource,
result.sources,
result.matchKind
);
deps.publish();
}