feat(website): add the alternative sources guide

New guide at /blog/alternative-sources-guide/: where the Sources chip comes
from (a local lookup across the reader's own Xtream playlists, never a search
outside them), how to read fact tags versus ~guesses, check availability,
switch playlists mid-film without losing the timecode, pin a preferred copy
per movie, and what the opt-in auto-switch does and on which players. Eight
FAQ entries, opening with the general ContentDisclaimer.

The two screenshots are mock-backed: the Xtream mock gains a marketing2
scenario that serves the identical marketing catalog under a second
credential pair (with a spec proving the catalogs match), the capture seeds it
as a "Fictional Xtream Backup" source only for shots that walk into it, opens
its category so the movies reach the local content cache that discovery reads,
and two new setup actions open the chip and the checked popover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 20:59:49 +02:00
1 parent 0d03140661
commit c7f1784dbd
17 files changed
+355 -8

No files matched your search

+47 -6
View File
@@ -23,6 +23,8 @@ import {
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
XTREAM_SECONDARY_FIXTURE_CREDENTIALS,
XTREAM_SECONDARY_FIXTURE_TITLE,
} from './capture-fixtures';
import {
clickDialogOption,
@@ -322,6 +324,12 @@ export async function waitForAppReady(page: Page): Promise<void> {
export interface SeedOptions {
/** Also add the Stalker marketing portal (guide shots only: it changes the dashboard). */
stalker?: boolean;
/**
* Also add the second marketing Xtream source (guide shots only). Its
* catalog is identical to the first, which is what makes the movie detail
* show a Sources chip.
*/
secondaryXtream?: boolean;
}
export async function seedDemoData(
@@ -329,11 +337,35 @@ export async function seedDemoData(
m3uPath: string,
options: SeedOptions = {}
): Promise<void> {
await addXtreamPortal(page);
await addXtreamPortal(page, {
title: XTREAM_FIXTURE_TITLE,
credentials: XTREAM_FIXTURE_CREDENTIALS,
provider: 'xtreams',
});
await addM3uPlaylist(page, m3uPath);
if (options.stalker) {
await addStalkerPortal(page);
}
if (options.secondaryXtream) {
await addXtreamPortal(page, {
title: XTREAM_SECONDARY_FIXTURE_TITLE,
credentials: XTREAM_SECONDARY_FIXTURE_CREDENTIALS,
provider: 'xtreams-secondary',
});
// Multi-source discovery reads the SQLite content table, which the
// desktop data source fills per category on first view. Open the
// category the guide shot walks into so its movies are cached before
// the primary portal's detail page looks for alternatives.
await page
.locator('app-workspace-context-panel .category-item')
.filter({ hasText: 'Action & Mystery' })
.first()
.click();
await page
.locator('.category-content-layout mat-card')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
}
await seedDashboardActivity(page);
}
@@ -405,15 +437,24 @@ async function seedDashboardActivity(page: Page): Promise<void> {
);
}
async function addXtreamPortal(page: Page): Promise<void> {
interface XtreamPortalSeed {
title: string;
credentials: { readonly username: string; readonly password: string };
provider: PlaylistProvider;
}
async function addXtreamPortal(
page: Page,
seed: XtreamPortalSeed
): Promise<void> {
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#title').fill(seed.title);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
await dialog.locator('#username').fill(seed.credentials.username);
await dialog.locator('#password').fill(seed.credentials.password);
await dialog
.getByRole('button', { name: /^(add|add playlist)$/i })
.last()
@@ -422,7 +463,7 @@ async function addXtreamPortal(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, {
timeout: 45_000,
});
registerPlaylistId('xtreams', idFromUrl(page.url(), 'xtreams'));
registerPlaylistId(seed.provider, idFromUrl(page.url(), 'xtreams'));
await page
.locator('.category-content-layout, app-content-card')
.first()
+12
View File
@@ -29,6 +29,18 @@ export const XTREAM_FIXTURE_CREDENTIALS = {
password: 'marketing',
} as const;
/**
* Second Xtream source for the alternative-sources guide: the mock's
* `marketing2` scenario serves the identical catalog, so every movie exists in
* both playlists and the detail page shows its Sources chip. Seeded only for
* shots that walk into it — it adds a card to the dashboard.
*/
export const XTREAM_SECONDARY_FIXTURE_TITLE = 'Fictional Xtream Backup';
export const XTREAM_SECONDARY_FIXTURE_CREDENTIALS = {
username: 'marketing2',
password: 'marketing2',
} as const;
/**
* Download folder the guide shots authorize inside the isolated data dir. The
* capture stubs Electron's folder dialog to return it, so no download ever
+51 -1
View File
@@ -21,7 +21,11 @@ import {
} from './capture-fixtures';
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider = 'playlists' | 'xtreams' | 'stalker';
export type PlaylistProvider =
| 'playlists'
| 'xtreams'
| 'xtreams-secondary'
| 'stalker';
const playlistIds = new Map<PlaylistProvider, string>();
@@ -350,6 +354,31 @@ export async function runAction(
await page.waitForTimeout(500);
return;
}
case 'open-xtream-vod-sources': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
return;
}
case 'open-xtream-vod-sources-menu': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
return;
}
case 'open-downloads-manager': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
@@ -452,6 +481,27 @@ async function goHome(page: Page): Promise<void> {
await settleUi(page);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodWithSources(
page: Page,
category: string
): Promise<void> {
requireId('xtreams-secondary');
await runAction(page, 'open-xtream-vod', category);
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Download manager (guide shots) */
/* ------------------------------------------------------------------ */
@@ -166,6 +166,15 @@ async function main(): Promise<void> {
const stalkerMockServer = needsStalker
? await driver.ensureStalkerMockServer(workspaceRoot)
: undefined;
// Same rule for the second Xtream source: another card on the dashboard,
// wanted only by the alternative-sources guide shots.
const needsSecondaryXtream = shots.some((shot: { setup: string[] }) =>
shot.setup.some((step) =>
parseSetupStep(String(step)).action.startsWith(
'open-xtream-vod-sources'
)
)
);
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
let app: Awaited<ReturnType<typeof driver.launchApp>> | undefined;
let page: Page | undefined;
@@ -227,6 +236,7 @@ async function main(): Promise<void> {
await assertTmdbDisabled(page); // G5
await driver.seedDemoData(page, driver.writeM3uFixture(dataDir), {
stalker: needsStalker,
secondaryXtream: needsSecondaryXtream,
});
for (const theme of themes) {
+2
View File
@@ -54,6 +54,8 @@ export const KNOWN_ACTIONS = [
'open-settings-epg',
'open-downloads-manager',
'open-downloads-offline-movie',
'open-xtream-vod-sources',
'open-xtream-vod-sources-menu',
];
/**
+16
View File
@@ -131,6 +131,22 @@
"setup": [
"open-downloads-offline-movie"
]
},
{
"slug": "guide-sources-chip",
"title": "Movie details with the Sources chip",
"group": "guides",
"setup": [
"open-xtream-vod-sources=Action & Mystery"
]
},
{
"slug": "guide-sources-menu",
"title": "Sources popover with checked copies",
"group": "guides",
"setup": [
"open-xtream-vod-sources-menu=Action & Mystery"
]
}
]
}
+7
View File
@@ -43,6 +43,13 @@ const GUIDES = [
'blog/guides/screenshots/guide-downloads-offline-movie-dark.png',
],
},
{
slug: 'alternative-sources-guide',
screenshots: [
'blog/guides/screenshots/guide-sources-chip-dark.png',
'blog/guides/screenshots/guide-sources-menu-dark.png',
],
},
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');