diff --git a/CLAUDE.md b/CLAUDE.md index b649ecd8d..eea50c021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -330,7 +330,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), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) 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), 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 diff --git a/apps/website/README.md b/apps/website/README.md index 5e53112b3..5cebf2b4b 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -29,8 +29,9 @@ Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or ## Download Pages -`/download/` plus `/download/windows/`, `/download/macos/` and `/download/linux/` -are per-OS landing pages (`apps/website/src/pages/download/`). They exist for +`/download/` plus `/download/windows/`, `/download/macos/`, `/download/linux/` +and `/download/docker/` (the self-hosted browser version: quick start, variables, +tags, FAQ, with `docker/README.md` as the reference behind it) are landing pages (`apps/website/src/pages/download/`). They exist for search visibility on "IPTVnator download" style queries and to spare users the 27-asset GitHub release page; each carries OS-specific install steps, requirements, an FAQ and `SoftwareApplication` / `FAQPage` / `BreadcrumbList` diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index 9f695618a..313e3c765 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -11,6 +11,7 @@ - Download for Windows: https://4gray.github.io/iptvnator/download/windows/ - Download for macOS: https://4gray.github.io/iptvnator/download/macos/ - Download for Linux: https://4gray.github.io/iptvnator/download/linux/ +- Self-host the browser version with Docker: https://4gray.github.io/iptvnator/download/docker/ - 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/ diff --git a/apps/website/src/components/DownloadCTA.astro b/apps/website/src/components/DownloadCTA.astro index f1cced931..a80da2166 100644 --- a/apps/website/src/components/DownloadCTA.astro +++ b/apps/website/src/components/DownloadCTA.astro @@ -85,7 +85,7 @@ const packageManagers = [

Prefer the browser version?{' '} Self-host it with Docker diff --git a/apps/website/src/components/download/PlatformSwitcher.astro b/apps/website/src/components/download/PlatformSwitcher.astro index 4bcef05ab..a33375c7b 100644 --- a/apps/website/src/components/download/PlatformSwitcher.astro +++ b/apps/website/src/components/download/PlatformSwitcher.astro @@ -3,11 +3,13 @@ import { DOWNLOAD_PLATFORMS, PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGE import type { DownloadPlatform } from '../../lib/platforms'; interface Props { - current?: DownloadPlatform; + current?: DownloadPlatform | 'docker'; } const { current } = Astro.props; const others = DOWNLOAD_PLATFORMS.filter((platform) => platform !== current); +const showDocker = current !== 'docker'; +const otherLabels = [...others.map((platform) => PLATFORM_LABELS[platform]), ...(showDocker ? ['Docker'] : [])]; ---

@@ -19,7 +21,7 @@ const others = DOWNLOAD_PLATFORMS.filter((platform) => platform !== current); Other platforms

Also available for{' '} - {others.map((platform) => PLATFORM_LABELS[platform]).join(' and ')} + {otherLabels.length > 1 ? `${otherLabels.slice(0, -1).join(', ')} and ${otherLabels.at(-1)}` : otherLabels[0]}

@@ -32,6 +34,14 @@ const others = DOWNLOAD_PLATFORMS.filter((platform) => platform !== current); IPTVnator for {PLATFORM_LABELS[platform]}
))} + {showDocker && ( + + + Browser version with Docker + + )} All downloads diff --git a/apps/website/src/pages/compare/desktop-vs-browser.astro b/apps/website/src/pages/compare/desktop-vs-browser.astro index f33654276..e87150e65 100644 --- a/apps/website/src/pages/compare/desktop-vs-browser.astro +++ b/apps/website/src/pages/compare/desktop-vs-browser.astro @@ -120,10 +120,13 @@ const jsonLd = buildComparisonPageSchema({ comparison, pageUrl, description, faq

- The application is then available on port 4333. Environment variables, reverse-proxy notes and the - limitations above are documented in{' '} + The application is then available on port 4333. Ports, variables, reverse-proxy notes and image tags are on + the{' '} + + Docker page + , and every detail in{' '} - the docker directory + the docker README .

diff --git a/apps/website/src/pages/download/docker.astro b/apps/website/src/pages/download/docker.astro new file mode 100644 index 000000000..51708b3df --- /dev/null +++ b/apps/website/src/pages/download/docker.astro @@ -0,0 +1,337 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Alert from '../../components/blog/Alert.astro'; +import CopyCommand from '../../components/blog/CopyCommand.astro'; +import FaqAccordion from '../../components/blog/FaqAccordion.astro'; +import LinkCards from '../../components/blog/LinkCards.astro'; +import StepRail from '../../components/blog/StepRail.astro'; +import DownloadSection from '../../components/download/DownloadSection.astro'; +import FeatureList from '../../components/download/FeatureList.astro'; +import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro'; +import PlatformSwitcher from '../../components/download/PlatformSwitcher.astro'; +import RelatedPosts from '../../components/download/RelatedPosts.astro'; +import SpecList from '../../components/download/SpecList.astro'; +import type { FaqEntry } from '../../lib/download-schema'; +import { REPO_URL } from '../../lib/downloads'; +import { absoluteUrl } from '../../lib/site'; + +const pageUrl = absoluteUrl('/download/docker/'); +const DOCKER_HUB_URL = 'https://hub.docker.com/r/4gray/iptvnator'; +const DOCKER_README_URL = `${REPO_URL}/blob/master/docker/README.md`; +const COMPOSE_URL = `${REPO_URL}/blob/master/docker/docker-compose.yml`; + +const title = 'Self-Host IPTVnator with Docker – The Browser Version'; +const description = + 'Run IPTVnator in your browser from one Docker container: the web app and its backend in a single image, published for amd64 and arm64. One compose command, the ports and variables that matter, what the browser version leaves out, and how to keep it updated.'; + +const quickStart = [ + 'Install Docker and Docker Compose on the machine that will host IPTVnator. A home server, a NAS or a spare computer all work; the image is published for amd64 and arm64.', + 'Save the compose file from the repository, or copy the command below into a directory of your choice.', + 'Start the container. On the first run Docker pulls the image; later runs start in seconds.', + 'Open the address in any browser on the same device and add your first source: an M3U playlist, an Xtream Codes login or a Stalker portal.', +]; + +const composeSnippet = `services: + iptvnator: + image: 4gray/iptvnator:latest + restart: unless-stopped + ports: + - "4333:80" + environment: + CLIENT_URL: http://localhost:4333`; + +const included = [ + { + title: 'The complete web app', + description: + 'The same interface as the desktop app: sources, dashboard, live TV, movies and series with details, favorites, history and search across sources.', + }, + { + title: 'A backend in the same image', + description: + 'Browsers block cross-origin requests, so the container ships an Express backend that fetches playlists and proxies Xtream and Stalker calls under /api. No second container.', + }, + { + title: 'Portal program guides', + description: + 'Xtream and Stalker sources publish their own schedule, and it shows in the channel list and under the player in the browser too.', + }, + { + title: 'Reachable from every device', + description: + 'Run it once on the server and open it from any laptop, tablet or phone on your network. Each browser keeps its own sources and history.', + }, + { + title: 'A guarded proxy', + description: + 'The backend accepts only http and https provider URLs, rejects credentials in URLs, and refuses private and loopback targets unless you opt in, so a public instance cannot be turned into a scanner of your network.', + }, + { + title: 'Health checks and restarts', + description: + 'The entrypoint starts the backend, waits for its health endpoint, then starts nginx; if either exits, the container exits and Compose restarts it.', + }, +]; + +const config = [ + { term: 'Image', value: '4gray/iptvnator on Docker Hub, built for linux/amd64 and linux/arm64.' }, + { term: 'Port', value: 'The container listens on 80; the compose file maps it to 4333 on the host. Change the host side to taste.' }, + { term: 'CLIENT_URL', value: 'The origin browsers use to open the app, for CORS. Set it to your public URL behind a reverse proxy; several origins can be comma-separated.' }, + { term: 'BACKEND_URL', value: 'Where the app finds its backend. Keep the default /api for the bundled proxy.' }, + { term: 'IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS', value: 'Off by default. Set to 1 only when your provider lives on your own LAN and the instance is limited to trusted users.' }, + { term: 'NODE_EXTRA_CA_CERTS', value: 'Path to a CA bundle you mount into the container, for providers behind a private certificate authority. Keeps TLS validation on.' }, +]; + +const tags = [ + { term: 'latest', value: 'Every merge to the main branch. The simplest choice for a home setup.' }, + { term: 'stable', value: 'The most recent tagged release that is not a prerelease.' }, + { term: 'v', value: 'The image built for that release tag, when you want the container to follow releases rather than every merge.' }, + { term: 'sha-', value: 'Pinned to one commit, for reproducible deployments and rollbacks.' }, +]; + +const faq: FaqEntry[] = [ + { + q: 'Is the browser version the same app as the desktop version?', + a: 'Yes, built from the same code. What it lacks is everything that needs to reach outside a browser tab: launching MPV or VLC, the embedded MPV engine, downloading files, storing an XMLTV guide, mapping EPG channels by hand, search across every source and the phone remote. Portal schedules from Xtream and Stalker sources still work.', + }, + { + q: 'Does it need a separate backend container?', + a: 'No. The image contains nginx for the web app and the backend that fetches playlists and proxies portal requests, wired together under one origin. The older standalone backend image is not needed.', + }, + { + q: 'Can I put it behind a reverse proxy with HTTPS?', + a: 'Yes. Point your proxy at the container port and set CLIENT_URL to the public https origin so the backend accepts requests from it. Keep the /api path intact, since the app expects the backend there.', + }, + { + q: 'Is it safe to expose to the internet?', + a: 'Treat it as a private service. The app has no user accounts, and the backend proxies requests to your providers on behalf of whoever can reach it. Put it behind your own authenticating proxy or a VPN, and leave the private-network proxy switch off.', + }, + { + q: 'Where is my data stored?', + a: 'In the browser you use, not in the container: playlist metadata in IndexedDB and portal user data in local storage. Another browser or device starts empty, and clearing site data removes the sources. The container itself keeps no state, so replacing it loses nothing.', + }, + { + q: 'A stream does not play in the browser. What now?', + a: 'Browser engines cannot decode every codec, and there is no external player to hand the stream to. Use the copy-URL action on the stream and open it in MPV, VLC or IINA yourself, or use the desktop app, which automates that step.', + }, + { + q: 'A provider works with wget inside the container but fails in the app.', + a: 'Usually a dual-stack host behind an IPv4-only VPN or Docker network. The backend already gives the IPv6-to-IPv4 fallback 2.5 seconds instead of Node\'s default; check the container logs for the hostname and error code, and the docker README describes the last-resort option that disables the racing entirely.', + }, +]; + +const jsonLd = [ + { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'IPTVnator', + applicationCategory: 'MultimediaApplication', + applicationSubCategory: 'IPTV player', + operatingSystem: 'Docker (linux/amd64, linux/arm64); any modern browser', + url: pageUrl, + downloadUrl: DOCKER_HUB_URL, + installUrl: pageUrl, + isAccessibleForFree: true, + license: `${REPO_URL}/blob/master/LICENSE`, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + author: { '@type': 'Person', name: '4gray', url: 'https://github.com/4gray' }, + sameAs: [REPO_URL, DOCKER_HUB_URL], + description, + }, + { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faq.map((entry) => ({ + '@type': 'Question', + name: entry.q, + acceptedAnswer: { '@type': 'Answer', text: entry.a }, + })), + }, + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') }, + { '@type': 'ListItem', position: 2, name: 'Download', item: absoluteUrl('/download/') }, + { '@type': 'ListItem', position: 3, name: 'Docker', item: pageUrl }, + ], + }, +]; +--- + + +
+
+ +
+ + +
+
+
+ + + + Download · Browser version +
+ +

+ IPTVnator in{' '} + a container +

+ +

+ The browser version of IPTVnator ships as one Docker image: the web app plus the backend that fetches + playlists and talks to portals. Run it on a home server, open it from any device on your network, and keep + the desktop app for the features a browser cannot offer. +

+ + + +
    + {['One image, no extra backend', 'amd64 & arm64', 'Port 4333 by default', 'Free & open source'].map((fact) => ( +
  • {fact}
  • + ))} +
+
+ +
+
+
+ The IPTVnator dashboard, which the browser version renders identically +
+
The same interface, served from your own container
+
+
+
+
+ + +
+ +
+ +
+
+ docker-compose.yml, image only +
+
{composeSnippet}
+
+ +

+ The app answers on{' '} + http://localhost:4333. + The full compose file with health check and all variables is{' '} + in the repository. +

+
+
+
+ + + + + + +
+
+

+ A browser tab cannot launch other programs, write files where it likes or keep a database the size of a TV + guide. So the browser version has no MPV or VLC handoff, no embedded MPV engine, no downloads or recordings, + no XMLTV guide and no manual channel mapping, no search across every source, and no phone remote. Xtream and + Stalker schedules still show, because they come from the provider on demand. +

+

+ The full side-by-side table is on the{' '} + desktop vs browser comparison. +

+
+ +

+ Sources, favorites and history are stored by the browser you use, not by the container. A second device + starts empty, and clearing site data removes them. Replacing the container loses nothing, since it keeps + no state of its own. +

+
+
+
+ + + +
+ +

+ Set CLIENT_URL to the public origin, for example{' '} + https://tv.example.home, or the backend rejects the browser's requests as cross-origin. Keep + the /api path, and put your own authentication or a VPN in front: the app has no accounts, and + whoever reaches it can use your providers. +

+
+
+
+ + + +
+ +

+ Images are published from the main branch and from release tags only; pull requests never publish. The docker + README lists every tag pattern, including commit-pinned ones for rollbacks. +

+
+
+ + +
+ +
+
+ + + +
+ +
+
+ +
+
+ + +
diff --git a/apps/website/src/pages/download/index.astro b/apps/website/src/pages/download/index.astro index 6f00b67e7..9dc55bbd0 100644 --- a/apps/website/src/pages/download/index.astro +++ b/apps/website/src/pages/download/index.astro @@ -88,7 +88,7 @@ const jsonLd = [

-
@@ -133,7 +151,7 @@ const jsonLd = [

Browser version:{' '} - + self-host it with Docker

diff --git a/docker/README.md b/docker/README.md index 4206c18bb..73fefd134 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,9 @@ # Self-hosted IPTVnator +User-facing overview with quick start, variables and FAQ: +. This file is the +reference behind it. + The self-hosted image contains both pieces required for the browser PWA: - Angular PWA static files served by nginx diff --git a/tools/testing/website-download-pages.test.mjs b/tools/testing/website-download-pages.test.mjs index 36f60dd7d..448623431 100644 --- a/tools/testing/website-download-pages.test.mjs +++ b/tools/testing/website-download-pages.test.mjs @@ -96,12 +96,42 @@ for (const [platform, page] of Object.entries(PAGES)) { }); } +test('docker page: canonical, schema, quick start and links', async () => { + const html = await readDist('download/docker/index.html'); + assert.match(html, new RegExp(` entry['@type'] === 'SoftwareApplication'); + assert.ok(app, 'Expected a SoftwareApplication entry.'); + assert.match(String(app.operatingSystem), /Docker/); + const faq = schema.find((entry) => entry['@type'] === 'FAQPage'); + assert.ok(faq && faq.mainEntity.length >= 5, 'Expected a FAQPage with at least five questions.'); + const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList'); + assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/download/docker/`); +}); + +test('the homepage and the platform pages link to the docker page', async () => { + const home = await readDist('index.html'); + assert.match(home, /href="\/iptvnator\/download\/docker\/"/); + for (const platform of Object.keys(PAGES)) { + const html = await readDist(PAGES[platform].path); + assert.match(html, /href="\/iptvnator\/download\/docker\/"/, `${platform} page should link to the docker page`); + } +}); + test('download hub links to every platform page', async () => { const html = await readDist('download/index.html'); assert.match(html, new RegExp(` { test('sitemap lists the download pages', async () => { const sitemap = await readDist('sitemap-0.xml'); - for (const path of ['download/', 'download/windows/', 'download/macos/', 'download/linux/']) { + for (const path of ['download/', 'download/windows/', 'download/macos/', 'download/linux/', 'download/docker/']) { assert.match(sitemap, new RegExp(`${SITE}/${path}`)); } });