diff --git a/apps/website/README.md b/apps/website/README.md
index c7e1e8279..93063381f 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -81,7 +81,8 @@ skip shows up in your output.
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
-`m3u-playlist-epg-setup-guide.mdx` and `offline-downloads-guide.mdx` in
+`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx` and
+`alternative-sources-guide.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
@@ -107,6 +108,10 @@ Evergreen how-to posts live in the blog collection next to release notes
authorizes a folder inside the isolated data dir rather than the real OS
Downloads folder (`installDownloadFolderDialogStub` in
`tools/release/capture-app-driver.ts`).
+ The alternative-sources shots seed a second Xtream source from the mock's
+ `marketing2` scenario (identical catalog, "Fictional Xtream Backup"), which is
+ what makes the Sources chip appear; like the Stalker portal it is added only
+ for shots that walk into it, because it adds a card to the dashboard.
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
each guide for the FAQPage schema, a link to the download hub and the presence
diff --git a/apps/website/public/blog/guides/screenshots/guide-sources-chip-dark.png b/apps/website/public/blog/guides/screenshots/guide-sources-chip-dark.png
new file mode 100644
index 000000000..ebb13200f
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-sources-chip-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-sources-chip-light.png b/apps/website/public/blog/guides/screenshots/guide-sources-chip-light.png
new file mode 100644
index 000000000..e00d5150c
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-sources-chip-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-sources-menu-dark.png b/apps/website/public/blog/guides/screenshots/guide-sources-menu-dark.png
new file mode 100644
index 000000000..071eef2fb
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-sources-menu-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-sources-menu-light.png b/apps/website/public/blog/guides/screenshots/guide-sources-menu-light.png
new file mode 100644
index 000000000..aecf0c5b1
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-sources-menu-light.png differ
diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt
index f90ff4ee3..51131e654 100644
--- a/apps/website/public/llms.txt
+++ b/apps/website/public/llms.txt
@@ -19,6 +19,7 @@
- Guide, Stalker/Ministra portal setup: https://4gray.github.io/iptvnator/blog/stalker-portal-setup-guide/
- Guide, M3U playlist and EPG setup: https://4gray.github.io/iptvnator/blog/m3u-playlist-epg-setup-guide/
- Guide, offline viewing with the download manager (desktop): https://4gray.github.io/iptvnator/blog/offline-downloads-guide/
+- Guide, alternative sources for a movie across your own playlists (desktop): https://4gray.github.io/iptvnator/blog/alternative-sources-guide/
- Features overview: https://4gray.github.io/iptvnator/features/
- Feature, M3U playlist player: https://4gray.github.io/iptvnator/features/m3u-player/
- Feature, Xtream Codes player: https://4gray.github.io/iptvnator/features/xtream-codes-player/
diff --git a/apps/website/src/content/blog/alternative-sources-guide.mdx b/apps/website/src/content/blog/alternative-sources-guide.mdx
new file mode 100644
index 000000000..8091eb7b0
--- /dev/null
+++ b/apps/website/src/content/blog/alternative-sources-guide.mdx
@@ -0,0 +1,161 @@
+---
+title: Alternative Sources in IPTVnator - The Same Movie from Another Playlist
+description: When a film exists in more than one of your Xtream Codes playlists, IPTVnator shows a Sources chip. How to compare the copies, switch mid-film without losing your place, pin a preferred playlist, and let the app switch on its own when a stream dies.
+pubDate: 2026-09-06
+author: 4gray
+heroImage: /iptvnator/blog/guides/screenshots/guide-sources-menu-dark.png
+tags:
+ - guide
+ - xtream-codes
+ - playback
+draft: false
+faq:
+- q: Does IPTVnator search the internet for other copies of a movie?
+ a: No. It only looks inside the playlists you added yourself, and only in the local catalog cache of the desktop app. Nothing is queried outside your own sources, and the app never suggests providers or content it does not already have on disk.
+- q: Why does a movie show no Sources chip?
+ a: The chip needs at least one copy in another Xtream Codes playlist of yours. It is not shown for series, for Stalker portals or M3U playlists, or in the browser version. Two copies also have to match by title, or by TMDB identity when metadata enrichment is on; a copy whose title carries a different year is treated as a different film on purpose.
+- q: What does Check availability actually do?
+ a: It asks the other playlist's portal for the stream details and sends one HEAD request to the stream address with that playlist's own playback headers. That is a request against your own subscription, not a stream, and the verdict is remembered for ten minutes. Nothing is checked until you ask, so an unchecked copy is shown without a verdict rather than as offline.
+- q: Do I lose my position when I switch playlists mid-film?
+ a: No. The switch carries the current timecode to the other copy, and the player keeps running instead of being rebuilt. Each copy keeps its own saved position afterwards, so resuming later from either playlist continues where that copy stopped.
+- q: What does pinning a source change?
+ a: The Play button of that movie starts from the pinned playlist instead of the one you opened the page in, and the pin survives restarts. It applies to that one movie only; there is no global playlist priority.
+- q: Why does the app warn that the dub may differ?
+ a: Two copies of a film can carry different audio tracks. IPTVnator warns only when both copies state their audio language as a fact from the provider, never from a guess read out of the title. If no warning appears, the languages are unknown, not confirmed equal.
+- q: Which players support automatic switching?
+ a: The built-in web players (HTML5, Video.js and ArtPlayer), because only they report a playback failure back to the app. With Embedded MPV or an external MPV or VLC the setting is hidden, and you switch by hand through the Sources chip.
+- q: Is auto-switch on by default?
+ a: No. It is off until you enable it in Settings under Playback or in the Sources popover. Even then the switch is always announced with an Undo, and it tries each copy at most once per session before showing the normal error screen.
+---
+
+import Alert from '../../components/blog/Alert.astro';
+import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
+import PostButton from '../../components/blog/PostButton.astro';
+import StepRail from '../../components/blog/StepRail.astro';
+import LinkCards from '../../components/blog/LinkCards.astro';
+
+Many people keep more than one subscription: a main one and a cheaper backup, or two providers
+that cover different countries. Their movie catalogs overlap, and when a stream from one dies at
+minute 40 the other one usually plays fine. Before v0.23 that meant leaving the film, opening the
+other playlist, finding the same title and scrubbing back. Now the detail page does the finding,
+and the switch keeps your place.
+
+This guide covers what the **Sources** chip shows, how to switch by hand, how to make one
+playlist the default for a movie, and when the app may switch on its own. It applies to the
+desktop app on Windows, macOS and Linux; movies from Xtream Codes sources only for now.
+
+
+
+## Where the chip comes from
+
+Open any movie. If the same film exists in at least one other Xtream Codes playlist you added,
+the action row under the poster gains a **Sources** chip with the number of copies the app
+found. Once the film plays, a line under the buttons says which playlist it is playing from and
+how many others have the title.
+
+
+
+The lookup is local and lazy. It runs against the catalog cache on your disk when the page
+opens, matches copies by title, and by TMDB identity when metadata enrichment is on, and it
+never contacts a provider until you ask for a check. A copy whose title states a different year
+is treated as a different film: *Dune (1984)* is not an alternative for the 2021 one.
+
+
+ Xtream Codes to Xtream Codes, movies only, desktop app only. Series, Stalker portals and M3U
+ playlists do not get a chip yet, and the browser version never shows one.
+
+
+## Compare the copies
+
+Click the chip to open the sources popover.
+
+
+
+Each playlist is a group; a playlist that holds several copies of the same film lists them
+under one heading. A row shows what is known about that copy:
+
+- **Plain tags** are facts: the container, codec, audio and resolution the provider's API
+ reported, or the result of a check.
+- **Tags starting with `~`** are guesses read out of the stream title, such as `~1080p` from a
+ name that ends in "1080p". They help you browse, but the app never uses a guess to decide
+ anything on its own.
+- **check** means the copy has not been contacted yet. It is not offline; nobody asked.
+
+**Check all** contacts every unchecked copy: one metadata request to that playlist's portal and
+one HEAD request to the stream, sent with that playlist's own playback headers. The row then
+reads *available* with the response time, or *unavailable*. Verdicts are kept for ten minutes,
+so reopening the page does not repeat the requests.
+
+The chips above the list narrow it down: **Available** keeps verified copies only (and runs the
+check for you when nothing has been checked yet), **HD+** keeps copies tagged 1080p or better,
+and the language select lists the languages the titles or category names actually carry. The
+search box filters by playlist name.
+
+## Switch while watching
+
+
+
+The player is not rebuilt, so fullscreen survives the switch. Every copy keeps its own saved
+position afterwards: the progress you make on the backup playlist is stored under that
+playlist, and resuming later from either one continues where that copy stopped.
+
+If the toast adds **the dub may differ**, both copies stated their audio language as a fact and
+the languages differ. No warning does not mean the dubs match; it means the app does not know.
+
+## Make one playlist the default for a movie
+
+**Make this the main source** on a row pins that copy. From then on the movie's **Play** and
+**Resume** buttons start from the pinned playlist, whichever playlist you opened the page in,
+and the pin survives restarts. The pin is per movie: a backup playlist that is better for one
+film is not made the default for your whole library. **Unpin this source** removes it.
+
+
+ The primary button shows the pinned copy's own position. A film you finished on the backup
+ playlist reads Play, not Resume, even if the main playlist's copy is still half-watched.
+
+
+## When a stream dies
+
+If playback fails and other copies exist, the error screen lists them instead of a dead end:
+pick one and the film continues at the same timecode. That works with every player.
+
+**Auto-switch on error** goes one step further and picks the next copy for you. It is off by
+default and can be turned on in Settings under Playback or with the toggle at the bottom of
+the Sources popover. When it is on and a stream fails:
+
+1. copies that already failed this session are skipped,
+2. a copy that was checked and found available is preferred, and a pinned copy wins,
+3. the switch is announced with a toast, an Undo button and the dub warning where it applies.
+
+Each copy is tried at most once per session, so a movie with three copies switches at most
+twice and then shows the normal error screen. The setting only appears with the built-in web
+players, because only they report a failure to the app; Embedded MPV, MPV and VLC play on their
+own and cannot trigger it.
+
+## Good to know
+
+- The chip counts copies, the caption counts playlists. Two copies in one backup playlist
+ read "Sources 3" and "also found in 1 other playlist".
+- Checks and switches use each playlist's own credentials and headers. Nothing from one
+ subscription is sent to the other provider.
+- A downloaded movie keeps its Sources chip: provider playback of an offline title still goes
+ through the popover.
+
+## Related
+
+
+
+
diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md
index 26962878b..73b4f06b9 100644
--- a/apps/xtream-mock-server/README.md
+++ b/apps/xtream-mock-server/README.md
@@ -115,6 +115,7 @@ media. Barriers and delays are coordination tools, not timing inputs.
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
+| `marketing2` | `marketing2` | same catalog, 2nd copy | 4 | 4 | 4 | curated | active |
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
@@ -230,6 +231,7 @@ application code.
- **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API)
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
+- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
- **Local download media**: `marketing:marketing` also serves `/movie/...` and `/series/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
- **Performance fixture**: `performance:performance` returns exactly 100,000
local-only catalog items from index-derived values; it does not use Faker,
diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts
index d117c74bf..e2dfa1dd8 100644
--- a/apps/xtream-mock-server/src/app/scenarios.ts
+++ b/apps/xtream-mock-server/src/app/scenarios.ts
@@ -196,6 +196,20 @@ export const SCENARIOS: Record = {
marketingFixture: true,
downloadStreamFixture: 'local-media',
},
+ 'marketing2:marketing2': {
+ name: 'marketing-demo-b',
+ description:
+ 'Second copy of the marketing catalog — a "backup subscription" for alternative-source screenshots',
+ seed: 8020,
+ categoryCount: { live: 4, vod: 4, series: 4 },
+ itemsPerCategory: 6,
+ seasonsPerSeries: 3,
+ episodesPerSeason: 6,
+ accountStatus: 'Active',
+ expiryDate: '2099-12-31',
+ marketingFixture: true,
+ downloadStreamFixture: 'local-media',
+ },
'expired:expired': {
name: 'expired',
description: 'Expired account — tests subscription expiry UI flow',
diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts
index 1902ce073..d00181479 100644
--- a/apps/xtream-mock-server/src/app/server.spec.ts
+++ b/apps/xtream-mock-server/src/app/server.spec.ts
@@ -147,6 +147,32 @@ describe('Xtream mock server factory', () => {
}
});
+ it('serves the marketing catalog twice so the second credential pair is an alternative source', async () => {
+ const running = await startLoopbackServer(
+ createXtreamMockApp({ host: '127.0.0.1', port: 0 })
+ );
+ try {
+ const titlesOf = async (username: string) => {
+ const response = await fetch(
+ `${running.origin}/player_api.php?username=${username}&password=${username}&action=get_vod_streams`
+ );
+ const items = (await response.json()) as Array<{
+ name: string;
+ stream_id: number;
+ }>;
+ return items.map((item) => `${item.stream_id}:${item.name}`);
+ };
+
+ const primary = await titlesOf('marketing');
+ const secondary = await titlesOf('marketing2');
+
+ expect(primary.length).toBeGreaterThan(0);
+ expect(secondary).toEqual(primary);
+ } finally {
+ await running.close();
+ }
+ });
+
it('serves marketing movies and episodes from local bytes so download screenshots stay hermetic', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts
index af31d79e7..70af02958 100644
--- a/tools/release/capture-app-driver.ts
+++ b/tools/release/capture-app-driver.ts
@@ -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 {
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 {
- 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 {
);
}
-async function addXtreamPortal(page: Page): Promise {
+interface XtreamPortalSeed {
+ title: string;
+ credentials: { readonly username: string; readonly password: string };
+ provider: PlaylistProvider;
+}
+
+async function addXtreamPortal(
+ page: Page,
+ seed: XtreamPortalSeed
+): Promise {
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 {
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()
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index 65f631616..393b6be3e 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -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
diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts
index bc2f2375d..f993bd05e 100644
--- a/tools/release/capture-navigation.ts
+++ b/tools/release/capture-navigation.ts
@@ -21,7 +21,11 @@ import {
} from './capture-fixtures';
/** Route segment of each seeded source, as it appears in `/workspace//`. */
-export type PlaylistProvider = 'playlists' | 'xtreams' | 'stalker';
+export type PlaylistProvider =
+ | 'playlists'
+ | 'xtreams'
+ | 'xtreams-secondary'
+ | 'stalker';
const playlistIds = new Map();
@@ -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 {
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 {
+ 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) */
/* ------------------------------------------------------------------ */
diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts
index f622fa3b3..59ce29171 100644
--- a/tools/release/capture-release-screenshots.ts
+++ b/tools/release/capture-release-screenshots.ts
@@ -166,6 +166,15 @@ async function main(): Promise {
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> | undefined;
let page: Page | undefined;
@@ -227,6 +236,7 @@ async function main(): Promise {
await assertTmdbDisabled(page); // G5
await driver.seedDemoData(page, driver.writeM3uFixture(dataDir), {
stalker: needsStalker,
+ secondaryXtream: needsSecondaryXtream,
});
for (const theme of themes) {
diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs
index b7ec2a45e..f46187e03 100644
--- a/tools/release/screenshot-guards.mjs
+++ b/tools/release/screenshot-guards.mjs
@@ -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',
];
/**
diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json
index 56d310658..2f5826ae9 100644
--- a/tools/release/screenshots.manifest.json
+++ b/tools/release/screenshots.manifest.json
@@ -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"
+ ]
}
]
}
diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs
index 751c8f8bb..3adf54b8f 100644
--- a/tools/testing/website-guides.test.mjs
+++ b/tools/testing/website-guides.test.mjs
@@ -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');