feat(website): add the offline downloads guide with a reusable content disclaimer

New guide at /blog/offline-downloads-guide/: what the desktop download manager
can save, choosing the folder, downloading a movie, episodes and seasons,
following the queue (pause, resume, automatic reconnects), the offline
library, and the Needs attention states, with a nine-question FAQ. The prose
frames the feature as offline viewing of content the reader already streams
and defers legality to the provider's terms and local law.

ContentDisclaimer.astro carries that notice in a general and an offline
variant so later guides reuse it instead of rewording it.

The three screenshots are mock-backed captures. The Xtream mock's marketing
scenario now serves movie and episode stream URLs from generated local bytes
(downloadStreamFixture: 'local-media'), because the capture's network gate
rejects the public HLS stub every other scenario redirects to; the capture
stubs Electron's folder dialog so "Change Folder" authorizes a folder inside
the isolated data dir rather than the real OS Downloads folder; two new setup
actions queue a movie and two episodes and open the manager and the offline
detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 19:41:10 +02:00
1 parent 15c2ac1f39
commit 0d03140661
23 files changed
+514 -15

No files matched your search

+1 -1
View File
@@ -350,7 +350,7 @@ This is an Nx monorepo with the following structure:
- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E
- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag/<tag>/` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD and open with `src/components/blog/ContentDisclaimer.astro`, whose `offline` variant is mandatory for posts about downloads or recordings; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag/<tag>/` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
- **libs/** - Shared libraries:
- **epg/data-access** - EPG services, runtime bridge, program normalization
- **m3u-state** - NgRx state management for M3U playlists
+18 -3
View File
@@ -80,9 +80,17 @@ skip shows up in your output.
## Guides
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx` and
`m3u-playlist-epg-setup-guide.mdx` in `apps/website/src/content/blog/`).
Two conventions set them apart:
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
`m3u-playlist-epg-setup-guide.mdx` and `offline-downloads-guide.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
`src/components/blog/ContentDisclaimer.astro` right after its intro: the
`general` variant states that IPTVnator ships no content, the `offline`
variant (downloads, recordings) adds what the feature is for and that keeping
a copy is governed by the provider's terms and local law. Reuse it instead of
rewriting the notice per post, and keep the surrounding prose to "content you
already stream", never "download from your provider".
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
renders it as an accordion after the body and emits a `FAQPage` JSON-LD block
@@ -92,6 +100,13 @@ Two conventions set them apart:
`apps/website/public/blog/guides/screenshots/<slug>-<theme>.png`; the shots are
declared in `tools/release/screenshots.manifest.json` with `"group": "guides"`
and never appear in a release run.
The download-manager shots need real transfers, so the Xtream mock's
`marketing` scenario serves movies and episodes from generated local bytes
(`downloadStreamFixture: 'local-media'`) instead of redirecting to the public
HLS stub, and the capture stubs Electron's folder dialog so "Change Folder"
authorizes a folder inside the isolated data dir rather than the real OS
Downloads folder (`installDownloadFolderDialogStub` in
`tools/release/capture-app-driver.ts`).
`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
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1
View File
@@ -18,6 +18,7 @@
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-setup-guide/
- 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/
- 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/
@@ -0,0 +1,38 @@
---
import Alert from './Alert.astro';
/**
* The standard "player, not a service" notice for guides. `general` is the
* short form; `offline` is for posts about downloads and recordings, where the
* reader also needs to hear what the feature is for and that the legality of
* keeping a copy depends on their provider's terms and their jurisdiction.
*/
interface Props {
variant?: 'general' | 'offline';
}
const { variant = 'general' } = Astro.props;
---
{variant === 'offline' ? (
<Alert type="info" title="What offline copies are for">
<p>
IPTVnator is a player. It ships without channels, playlists or subscriptions and does not
provide, host or sell any content. The download manager saves a local copy of something you
already stream, for personal offline viewing on this device: on a train, on a plane, on a
connection that keeps dropping. It does not bypass DRM and cannot reach anything your account
cannot already play.
</p>
<p class="!mt-2">
Whether keeping a copy is allowed depends on your provider's terms and on the law where you
live. Check both before you rely on it. The screenshots below come from the project's own mock
servers with fictional titles.
</p>
</Alert>
) : (
<Alert type="info" title="IPTVnator is a player, not a service">
It ships without channels, playlists or subscriptions and does not provide, host or sell any
content. Everything shown here comes from the project's own mock servers with fictional data.
Use it only with sources you have the right to access.
</Alert>
)}
@@ -0,0 +1,179 @@
---
title: How to Download Movies and Episodes for Offline Viewing in IPTVnator
description: Save a movie or a whole season from your own Xtream Codes or Stalker source to disk with the desktop app, follow the queue, resume interrupted transfers, and watch from the offline library without a connection.
pubDate: 2026-09-06
author: 4gray
heroImage: /iptvnator/blog/guides/screenshots/guide-downloads-manager-dark.png
tags:
- guide
- xtream-codes
- stalker-portal
draft: false
faq:
- q: Is it legal to download movies and episodes with IPTVnator?
a: That depends on your provider's terms and on the law where you live, not on the app. IPTVnator only saves a copy of something your account can already stream, for personal offline viewing on the same device. It does not bypass DRM, does not provide any content, and cannot reach titles your subscription does not include. If your provider's terms forbid saving copies, do not use the feature with that source.
- q: Where are the files saved, and can I move them?
a: In your system Downloads folder unless you pick another one with Change Folder on the Downloads page. The files are ordinary media files that any player can open. If you move or rename one, the manager marks it as File missing; it keeps the entry so you can download it again or remove it.
- q: Which file format do I get?
a: Exactly what the provider serves, usually an mp4 or mkv file. IPTVnator does not convert, re-encode or strip anything; the bytes on disk are the bytes the stream would have delivered to the player.
- q: Does a download count as a connection on my account?
a: While a file is transferring it uses one connection, just like a playing stream. IPTVnator downloads one file at a time and queues the rest, so a season never opens six connections at once. If your subscription allows a single connection, wait for the queue to finish before you start playing.
- q: Can I download live TV?
a: No. Live channels have no file to save. The Embedded MPV player can record a live stream while you watch it, and recordings appear in the same manager under Recordings; that feature is still experimental and described in the v0.23 release notes.
- q: Does this work in the browser version or the Docker image?
a: No. Downloads need the desktop app on Windows, macOS or Linux. The browser version has no access to your disk, so the download buttons are not shown there.
- q: What happens to my downloads if I delete the source playlist?
a: Nothing. Downloads are stored independently of the source, so the files and their library cards stay. Only View in portal is disabled until the source exists again.
- q: Why does a download stop at the same size every time?
a: Some panels cap every connection at a few hundred megabytes or a few minutes. IPTVnator reconnects automatically and continues from where the transfer stopped, verifying the last part of the file before it appends. If a transfer still ends up in Needs attention, Retry resumes it; it never starts over from zero as long as the partial file is on disk.
- q: Does Remove delete the file?
a: For a finished download, no. Remove and Clear finished take the entry out of the manager and leave the media file on disk. For a failed or canceled download, the retained partial data is deleted, because it can no longer be resumed.
---
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';
A long flight, a train through a tunnel, a holiday flat with Wi-Fi that gives up every evening:
that is what the download manager in IPTVnator is for. It saves a movie or a batch of episodes
from a source you already have to your disk, and later plays them from an offline library that
works without any connection to the provider.
This guide shows how to start a download, what the queue does while the file transfers, how to
watch what you saved, and what to do when a transfer stops. It applies to the desktop app on
Windows, macOS and Linux.
<ContentDisclaimer variant="offline" />
## What you can save
- **Movies and series episodes** from Xtream Codes and Stalker sources. Every movie detail page
and every episode row has a download button.
- **Not live TV.** A live channel has no file to save. If you want to keep a broadcast, the
Embedded MPV player can record it while it plays; recordings land in the same manager.
- **Desktop only.** The browser version and the Docker image cannot write to your disk, so they
show no download buttons.
IPTVnator transfers **one file at a time** and queues the rest. That keeps the load on your
provider the same as one playing stream, and it keeps season downloads from opening a dozen
connections at once.
## Choose where the files go
Open the manager from the download icon in the header. The folder line at the top shows where
new files will be written: your system Downloads folder by default. **Change Folder** opens the
system folder picker; the choice is remembered. The **Tracked downloads** figure next to it is
the disk space used by everything the manager knows about.
<Alert type="info" title="Folders and permissions">
IPTVnator writes only into the folder you selected. Downloads that were saved to an earlier
folder stay where they are and keep playing from there.
</Alert>
## Download a movie
<StepRail
title="From the movie page"
steps={[
'Open the movie in its source. The action row under the poster has Play, a heart for favorites and a download icon.',
'Click the download icon. It turns into a progress ring while the file transfers; you can leave the page, the transfer continues in the background.',
'When the ring becomes a check mark the file is on disk. Clicking the check mark reveals the file in your file manager.',
]}
/>
![Movie detail page with the download action in the row under the poster](/iptvnator/blog/guides/screenshots/guide-downloads-movie-detail-dark.png)
The title, poster, description and the metadata shown on the page are stored with the download,
so the offline library can show them even when the provider is unreachable.
## Download episodes and whole seasons
Every episode row on a series page carries the same download button. For more than one episode
at a time, use the **Download season** button in the season header: it queues every episode of
the selected season that is not already queued, paused or downloaded, and reports how many were
added and how many were skipped. Specials count as a season of their own.
<Alert type="success" title="Already have some of it?">
Download season is safe to click again. Episodes that are already on disk or already in the
queue are skipped, so re-running it only fills the gaps.
</Alert>
## Follow the queue
![Download manager with an episode transferring in the queue and a finished movie in the offline library](/iptvnator/blog/guides/screenshots/guide-downloads-manager-dark.png)
The Downloads page has two parts. **Downloading now** at the top lists the active transfer with
the bytes received so far and the percentage, followed by everything waiting in line. Each row offers **Pause**,
**Resume** and **Cancel**:
- **Pause** keeps the partial file. Resume continues from the same byte, also after you closed
the app or it crashed; unfinished transfers come back as paused, not lost.
- **Cancel** stops the transfer and discards the partial file.
- Connection drops are handled for you. When a provider cuts a long connection, IPTVnator
reconnects and continues from where it stopped, verifying the tail of the file before it
appends anything. Panels that cap every connection at a few hundred megabytes no longer need a
click per slice.
The filter chips narrow the page to **Movies**, **Series**, **In progress** or **Recordings**,
and the search box filters by title. Neither changes the tracked size in the header, so hiding a
card never makes its disk space look free.
## Watch offline
![Offline detail of a downloaded movie with Play and the file actions](/iptvnator/blog/guides/screenshots/guide-downloads-offline-movie-dark.png)
Finished files appear under **Ready to watch** as poster cards, marked **Available offline** in their detail: movies on their own, series
grouped with their downloaded episodes, recordings with the channel logo. A card opens the
offline detail, which plays the local file and needs no connection.
- **Play offline** opens the local file in the app's player. Nothing is requested from the provider.
- The **⋮ menu** on the poster has **Show in folder**, **Copy download URL** and **Remove**.
- A series detail lists only the episodes you actually have. Each episode plays its own file.
- **View in portal** jumps back to the provider page of the same title when the source can still
be reached, for example to download the next season.
The library is also reachable from inside a source: the Downloads entry in the sidebar of an
Xtream or Stalker source shows the same manager filtered to that source.
## When something needs attention
Transfers that stopped without finishing move to a **Needs attention** section:
- **Interrupted** means the connection dropped more often than the automatic reconnect could
handle. **Retry** resumes from the retained partial file.
- **Failed** with another reason usually means the provider refused the request or the file
disappeared on the server. Retry asks again; if it keeps failing, play the title once in the
app to check that the source still works.
- **File missing** appears when a finished file was moved, renamed or deleted outside the app.
**Download again** fetches it once more; **Remove** forgets the entry.
- **Clear finished** in the header removes completed, failed and canceled entries from the
page. Finished media files stay on disk; only the partial data of failed and canceled
transfers is deleted.
<Alert type="warning" title="Removing a source">
Deleting a playlist does not delete its downloads. The files and their cards stay in the
library; only View in portal is unavailable until the source is added again.
</Alert>
## Good to know
- Downloads carry the same User-Agent and Referer as playback, so a provider that checks them
accepts the transfer just like it accepts the player.
- The file is saved exactly as the provider serves it. There is no conversion, so a `.mkv`
stays a `.mkv` and plays in any player that handles the container.
- The local database keeps the download entries; a backup and restore of your sources does not
move the media files themselves.
## Related
<LinkCards
links={[
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'The desktop app for Windows, macOS and Linux is where downloads live.', icon: 'download' },
{ label: 'Add an Xtream Codes account', href: '/iptvnator/blog/xtream-codes-setup-guide/', hint: 'Connect the source whose movies and series you want to save.', icon: 'docs' },
{ label: 'v0.23 release notes', href: '/iptvnator/blog/v0-23-release-notes/', hint: 'The release that turned the queue into an offline library, with Live-TV recordings.', icon: 'docs' },
]}
/>
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
+1
View File
@@ -230,6 +230,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`
- **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,
`Date.now()`, `Math.random()`, external artwork, or external media URLs
+9 -2
View File
@@ -21,8 +21,14 @@ export interface ScenarioConfig {
performanceFixture?: 'catalog-100k';
/** Build series details on demand instead of during portal initialization. */
deferSeriesDetails?: true;
/** Optional local stream fixture for deterministic download queue tests. */
downloadStreamFixture?: 'slow-series';
/**
* Optional local stream fixture. `slow-series` throttles series episodes
* for download-queue tests; `local-media` serves movies quickly and
* episodes slowly from generated bytes so download screenshots complete
* without any request leaving the machine (the release capture's network
* gate rejects the public HLS stub every other scenario redirects to).
*/
downloadStreamFixture?: 'slow-series' | 'local-media';
}
/**
@@ -188,6 +194,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
accountStatus: 'Active',
expiryDate: '2099-12-31',
marketingFixture: true,
downloadStreamFixture: 'local-media',
},
'expired:expired': {
name: 'expired',
@@ -147,6 +147,46 @@ describe('Xtream mock server factory', () => {
}
});
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 })
);
try {
const movie = await fetch(
`${running.origin}/movie/marketing/marketing/62000.mp4`,
{ redirect: 'manual' }
);
const episode = await fetch(
`${running.origin}/series/marketing/marketing/80000.mkv`,
{ redirect: 'manual' }
);
const ordinaryMovie = await fetch(
`${running.origin}/movie/user1/pass1/62000.mp4`,
{ redirect: 'manual' }
);
for (const local of [movie, episode]) {
expect(local.status).toBe(200);
expect(local.headers.get('content-type')).toContain(
'video/mp4'
);
expect(
Number(local.headers.get('content-length'))
).toBeGreaterThan(1024 * 1024);
await local.body?.cancel();
}
// Movies must finish quickly while episodes stay slow enough to
// keep a progress row in frame.
expect(
Number(movie.headers.get('content-length'))
).toBeGreaterThan(Number(episode.headers.get('content-length')));
expect(ordinaryMovie.status).toBe(302);
} finally {
await running.close();
}
});
it('serves the download queue series fixture locally without changing ordinary series redirects', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
+36 -8
View File
@@ -15,8 +15,12 @@ import {
XtreamPerformanceController,
} from './performance-control.js';
import { dispatchAction } from './routes/dispatch.js';
import { getScenario } from './scenarios.js';
import { streamSlowSeriesDownload } from './slow-series-download.js';
import { getScenario, type ScenarioConfig } from './scenarios.js';
import {
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS,
streamSlowSeriesDownload,
} from './slow-series-download.js';
export { createXtreamMockServerShutdown } from './server-lifecycle.js';
@@ -255,20 +259,44 @@ function installStreamRoutes(
}
response.redirect(HLS_STUB);
};
const movieResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
if (downloadStreamFixtureOf(request) === 'local-media') {
streamSlowSeriesDownload(
request,
response,
LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS
);
return;
}
response.redirect(HLS_STUB);
};
const seriesResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
if (isSlowSeriesDownloadRequest(request)) {
const fixture = downloadStreamFixtureOf(request);
if (fixture === 'slow-series') {
streamSlowSeriesDownload(request, response);
return;
}
if (fixture === 'local-media') {
streamSlowSeriesDownload(
request,
response,
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS
);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
app.get('/live/:username/:password/:streamId.ts', streamResponse);
app.get('/movie/:username/:password/:streamId.:ext', streamResponse);
app.get('/movie/:username/:password/:streamId.:ext', movieResponse);
app.get('/series/:username/:password/:streamId.:ext', seriesResponse);
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
@@ -277,12 +305,12 @@ function installStreamRoutes(
app.all('/streaming/timeshift.php', streamResponse);
}
function isSlowSeriesDownloadRequest(request: Request): boolean {
function downloadStreamFixtureOf(
request: Request
): ScenarioConfig['downloadStreamFixture'] {
const username = String(request.params['username'] ?? '');
const password = String(request.params['password'] ?? '');
return (
getScenario(username, password).downloadStreamFixture === 'slow-series'
);
return getScenario(username, password).downloadStreamFixture;
}
function isPerformanceMediaRequest(
@@ -12,6 +12,23 @@ export const DEFAULT_SLOW_SERIES_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
totalBytes: 8 * 1024 * 1024,
};
/**
* `local-media` fixture: a movie finishes in well under a second so the
* library shows a completed card, while an episode trickles for ~20 s so the
* queue still shows a live progress row when the screenshot is taken.
*/
export const LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
chunkSize: 2 * 1024 * 1024,
intervalMs: 2,
totalBytes: 96 * 1024 * 1024,
};
export const LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
chunkSize: 256 * 1024,
intervalMs: 100,
totalBytes: 48 * 1024 * 1024,
};
export function streamSlowSeriesDownload(
request: Request,
response: Response,
+21 -1
View File
@@ -6,7 +6,7 @@
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import {
_electron as electron,
@@ -241,6 +241,26 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/* Launch and readiness */
/* ------------------------------------------------------------------ */
/**
* Replaces Electron's native folder picker with one that answers `folder`
* (created here) — the same trick `downloads.e2e.ts` uses. The downloads
* page's "Change Folder" button then authorizes a folder inside the isolated
* data dir, so guide shots never write into the real OS Downloads folder.
*/
export async function installDownloadFolderDialogStub(
app: ElectronApplication,
folder: string
): Promise<void> {
mkdirSync(folder, { recursive: true });
await app.evaluate(({ dialog }, target) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [target],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, folder);
}
export async function launchApp(
electronMainPath: string,
env: Record<string, string>,
+7
View File
@@ -29,6 +29,13 @@ export const XTREAM_FIXTURE_CREDENTIALS = {
password: 'marketing',
} 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
* lands in the real OS Downloads folder and no personal path reaches a frame.
*/
export const CAPTURE_DOWNLOAD_FOLDER_NAME = 'IPTVnator downloads';
/** Fictional playlist and guide addresses typed into forms for the M3U guide shots; never fetched. */
export const M3U_FIXTURE_PLAYLIST_URL = `${XTREAM_MOCK_ORIGIN}/demo/channels.m3u8`;
export const M3U_FIXTURE_PLAYLIST_TITLE = 'Fictional TV playlist';
+107
View File
@@ -8,6 +8,7 @@ import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
EPG_FIXTURE_URL,
M3U_FIXTURE_PLAYLIST_TITLE,
M3U_FIXTURE_PLAYLIST_URL,
@@ -349,6 +350,27 @@ export async function runAction(
await page.waitForTimeout(500);
return;
}
case 'open-downloads-manager': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
return;
}
case 'open-downloads-offline-movie': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
const card = page
.locator('[data-test-id^="download-library-movie-"]')
.first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.locator('.download-library__artwork-button').click();
await page
.locator('[data-testid="offline-play"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
default:
throw new Error(`Unknown setup action: ${action}`);
}
@@ -430,6 +452,91 @@ async function goHome(page: Page): Promise<void> {
await settleUi(page);
}
/* ------------------------------------------------------------------ */
/* Download manager (guide shots) */
/* ------------------------------------------------------------------ */
/** Header shortcut into `/workspace/downloads`, the same button the e2e suite uses. */
async function openDownloadsPage(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Open downloads' }).click();
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/, {
timeout: 20_000,
});
await page
.locator('[data-test-id="downloads-content"]')
.waitFor({ state: 'visible', timeout: 20_000 });
await settleUi(page);
}
/**
* Puts the download manager into the state the offline guide describes: the
* authorized folder inside the isolated data dir, one finished movie in the
* library and two episodes trickling through the queue. Every step
* is idempotent, because the manifest runs each shot once per theme: a movie
* already saved shows the done state instead of the download button, and a
* queued or saved episode no longer offers a download label.
*/
async function prepareGuideDownloads(page: Page): Promise<void> {
await openDownloadsPage(page);
await ensureCaptureDownloadFolder(page);
await openXtreamSection(page, 'vod', 'Action & Mystery');
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
const downloadMovie = page.locator('[data-testid="vod-download-start"]');
if (await downloadMovie.isVisible().catch(() => false)) {
await downloadMovie.click();
await page
.locator(
'[data-testid="vod-download-progress"], [data-testid="vod-download-done"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
}
await runAction(page, 'open-xtream-series', 'Urban Drama');
// Two single episodes rather than "Download season": a six-episode queue
// fills the whole frame and pushes the finished movie below the fold. An
// episode button whose label no longer starts with "Download" is already
// queued or saved (it would play the local file), so it is left alone.
const episodeButtons = page.locator(
'[data-test-id^="episode-download-"][aria-label^="Download "]:not([disabled])'
);
const toQueue = Math.min(await episodeButtons.count(), 2);
for (let index = 0; index < toQueue; index += 1) {
await episodeButtons.first().click();
await page.waitForTimeout(800);
}
await settleUi(page);
}
/**
* The download manager authorizes the OS Downloads folder by default, which
* would put fixture bytes into the maintainer's real Downloads directory and
* show a personal home path in the frame. The capture stubs the folder dialog
* (`installDownloadFolderDialogStub`), so "Change Folder" lands on the
* isolated folder without any native UI.
*/
async function ensureCaptureDownloadFolder(page: Page): Promise<void> {
const folder = page.locator('[data-test-id="downloads-folder"]');
await folder.waitFor({ state: 'visible', timeout: 20_000 });
if ((await folder.innerText()).includes(CAPTURE_DOWNLOAD_FOLDER_NAME)) {
return;
}
await page.getByRole('button', { name: 'Change Folder' }).click();
await page
.locator('[data-test-id="downloads-folder"]')
.filter({ hasText: CAPTURE_DOWNLOAD_FOLDER_NAME })
.waitFor({ state: 'visible', timeout: 20_000 });
}
async function openXtreamSection(
page: Page,
section: 'vod' | 'series',
@@ -52,6 +52,7 @@ import {
validateReleaseSlug,
} from './screenshot-guards.mjs';
import * as driver from './capture-app-driver';
import { CAPTURE_DOWNLOAD_FOLDER_NAME } from './capture-fixtures';
import {
applyTheme,
discardUnsavedSettings,
@@ -191,6 +192,10 @@ async function main(): Promise<void> {
await installRequestRecorder(app, networkPolicy());
page = await driver.findMainWindow(app);
await driver.installDownloadFolderDialogStub(
app,
path.join(dataDir, CAPTURE_DOWNLOAD_FOLDER_NAME)
);
// G3, second layer: page-level deny-by-default, which also blocks.
await page.route('**/*', async (route) => {
+2
View File
@@ -52,6 +52,8 @@ export const KNOWN_ACTIONS = [
'open-stalker-live',
'open-add-playlist-m3u-url',
'open-settings-epg',
'open-downloads-manager',
'open-downloads-offline-movie',
];
/**
+24
View File
@@ -107,6 +107,30 @@
"setup": [
"open-settings-epg"
]
},
{
"slug": "guide-downloads-movie-detail",
"title": "Movie details with the download action",
"group": "guides",
"setup": [
"open-xtream-vod=Action & Mystery"
]
},
{
"slug": "guide-downloads-manager",
"title": "Download manager: queue and offline library",
"group": "guides",
"setup": [
"open-downloads-manager"
]
},
{
"slug": "guide-downloads-offline-movie",
"title": "Offline movie detail",
"group": "guides",
"setup": [
"open-downloads-offline-movie"
]
}
]
}
+8
View File
@@ -35,6 +35,14 @@ const GUIDES = [
'blog/guides/screenshots/guide-epg-settings-dark.png',
],
},
{
slug: 'offline-downloads-guide',
screenshots: [
'blog/guides/screenshots/guide-downloads-movie-detail-dark.png',
'blog/guides/screenshots/guide-downloads-manager-dark.png',
'blog/guides/screenshots/guide-downloads-offline-movie-dark.png',
],
},
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');