feat(website): add per-OS download landing pages

Add /download/ with Windows, macOS and Linux landing pages: OS-specific
install steps, requirements, feature list, FAQ, related posts and a
platform switcher, plus SoftwareApplication / FAQPage / BreadcrumbList
JSON-LD on every page.

Direct asset links resolve the latest published release from the GitHub
Releases API at build time (asset names, sizes, publish date) and fall
back to the root package.json version when the API is unreachable;
WEBSITE_SKIP_RELEASE_FETCH=1 forces the fallback. The deploy workflow
passes GITHUB_TOKEN to the build. The homepage download cards and the
header Download link now point at the new pages, the homepage schema
reads the resolved version instead of a hard-coded 0.20.0, and the
locale count is corrected to 19.

tools/testing/website-download-pages.test.mjs checks titles, canonicals,
direct asset links, structured data, cross-links and sitemap entries of
the built output; nx test website runs it beside the Giscus test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-03 13:08:53 +02:00
1 parent fd963bb0c5
commit 078bd07d94
27 files changed
+1906 -22

No files matched your search

+3
View File
@@ -40,6 +40,9 @@ jobs:
- name: Build website
run: pnpm nx build website
env:
# Read by apps/website/src/lib/downloads.ts to resolve the latest published release.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
+1 -1
View File
@@ -319,7 +319,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 and blog
- **apps/website** - Astro + Tailwind landing page, blog, and per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`); 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
+36
View File
@@ -26,3 +26,39 @@ gh api graphql \
Then update `data-category-id` in `GiscusComments.astro`.
Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI.
## Download Pages
`/download/` plus `/download/windows/`, `/download/macos/` and `/download/linux/`
are per-OS landing pages (`apps/website/src/pages/download/`). They exist for
search visibility on "IPTVnator <OS> 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`
structured data. Shared pieces live in `src/components/download/` and reuse the
blog components (`StepRail`, `Alert`, `FaqAccordion`, `CopyCommand`).
### Latest-release resolution
Direct asset links need the release version, so `src/lib/downloads.ts`
resolves it at build time:
1. `GET https://api.github.com/repos/4gray/iptvnator/releases/latest` (8 s
timeout). The asset list from the *published* release is authoritative:
options whose file is missing are dropped, sizes and the publish date come
from the API. `deploy-website.yml` passes `GITHUB_TOKEN` to the build so
the call is authenticated.
2. Fallback: the root `package.json` version with the asset naming pattern
from `electron-builder.json`. This is deterministic but cannot prove the
files exist yet (a version bump lands on `master` before the release is
published), so a warning is printed. Set `WEBSITE_SKIP_RELEASE_FETCH=1` to
force it for offline or reproducible builds.
Both paths produce the same page structure. Adding an artifact means adding a
`DownloadOption` (matcher + fallback name) in `downloads.ts`; the pages and the
hub pick it up. The homepage `SoftwareApplication` schema reads the same
resolved version.
`pnpm nx test website` builds the site and runs
`tools/testing/website-download-pages.test.mjs`, which checks titles,
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
without depending on a specific version.
+1 -1
View File
@@ -18,7 +18,7 @@
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"command": "node --test tools/testing/website-giscus-comments.test.mjs"
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs"
}
},
"serve": {
+4
View File
@@ -7,6 +7,10 @@
- Website: https://4gray.github.io/iptvnator/
- Blog: https://4gray.github.io/iptvnator/blog/
- Latest Release: https://github.com/4gray/iptvnator/releases/latest
- Downloads overview: https://4gray.github.io/iptvnator/download/
- 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/
- Release Notes (v0.18): https://4gray.github.io/iptvnator/blog/v0-18-release-notes/
- Troubleshooting (macOS app damaged fix): https://4gray.github.io/iptvnator/blog/macos-arm-app-damaged-fix/
- Source Code: https://github.com/4gray/iptvnator
+9 -17
View File
@@ -1,21 +1,13 @@
---
const platforms = [
{
name: 'macOS',
icon: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
{
name: 'Windows',
icon: 'M3 12V6.75l8-1.25V12H3zm0 .5h8v6.5l-8-1.25V12.5zM11.5 5.35L21 3.75V12h-9.5V5.35zm0 7.15H21v8.25l-9.5-1.6V12.5z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
{
name: 'Linux',
icon: 'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.368 1.884 1.43.39.03.8-.066 1.109-.199.69-.3 1.286-1.065 1.09-1.745a21.03 21.03 0 01-.174-.606c-.101-.427-.2-.855-.29-1.283-.09-.428.04-.546.481-.735.463-.199.833-.338 1.073-.668.24-.33.364-.8.269-1.437-.14-.98-.689-2.072-1.329-3.069-.645-1-.34-1.665-.34-2.863.006-1.497-.898-2.987-1.792-4.048-.89-1.06-1.78-1.835-2.085-2.748-.228-.68-.098-1.401.128-2.056.112-.322.254-.631.325-.933.07-.303.072-.622-.058-.853C13.56.199 13.04 0 12.504 0z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
];
import { PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGES } from '../lib/platforms';
import type { DownloadPlatform } from '../lib/platforms';
const platformOrder: DownloadPlatform[] = ['macos', 'windows', 'linux'];
const platforms = platformOrder.map((platform) => ({
name: PLATFORM_LABELS[platform],
icon: PLATFORM_ICON_PATHS[platform],
href: PLATFORM_PAGES[platform],
}));
const packageManagers = [
{ label: 'Homebrew', command: 'brew install --cask iptvnator' },
@@ -41,7 +41,7 @@ const carouselFeatures = [
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
title: 'Multi-Language',
description: 'Available in 16 languages with community-contributed translations.',
description: 'Available in 19 languages with community-contributed translations.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
+1 -1
View File
@@ -2,7 +2,7 @@
const navLinks = [
{ label: 'Features', href: '/iptvnator/#features' },
{ label: 'Screenshots', href: '/iptvnator/#screenshots' },
{ label: 'Download', href: '/iptvnator/#download' },
{ label: 'Download', href: '/iptvnator/download/' },
{ label: 'Blog', href: '/iptvnator/blog/' },
];
---
@@ -0,0 +1,29 @@
---
import type { ResolvedDownload } from '../../lib/downloads';
import { formatAssetSize } from '../../lib/downloads';
interface Props {
download: ResolvedDownload;
primary?: boolean;
}
const { download, primary = false } = Astro.props;
const size = formatAssetSize(download.size);
const detail = size ? `${download.detail} · ${size}` : download.detail;
const baseClass = 'group inline-flex items-center gap-4 rounded-xl px-6 py-4 transition-all duration-300';
const variantClass = primary
? 'bg-accent-500 text-surface-950 shadow-[0_0_30px_rgba(32,168,168,0.25)] hover:-translate-y-0.5 hover:bg-accent-400 hover:shadow-[0_0_44px_rgba(32,168,168,0.4)]'
: 'border border-dashed border-surface-600/60 bg-surface-900/50 text-surface-100 hover:border-accent-500/40 hover:bg-accent-500/[0.05]';
const detailClass = primary ? 'text-surface-950/70' : 'text-surface-500';
---
<a href={download.url} class={`${baseClass} ${variantClass}`} data-download={download.id} rel="noopener">
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="flex flex-col text-left">
<span class="text-base font-semibold leading-tight">{download.label}</span>
<span class={`mt-1 font-mono text-[10px] uppercase tracking-[0.12em] ${detailClass}`}>{detail}</span>
</span>
</a>
@@ -0,0 +1,107 @@
---
import DownloadButton from './DownloadButton.astro';
import type { ResolvedDownload, ResolvedRelease } from '../../lib/downloads';
import { LATEST_RELEASE_URL, formatReleaseDate } from '../../lib/downloads';
import { PLATFORM_ICON_PATHS, PLATFORM_LABELS } from '../../lib/platforms';
import type { DownloadPlatform } from '../../lib/platforms';
interface Screenshot {
src: string;
alt: string;
width: number;
height: number;
}
interface Props {
platform: DownloadPlatform;
eyebrow: string;
titleLead: string;
titleAccent: string;
lede: string;
release: ResolvedRelease;
downloads: ResolvedDownload[];
facts: string[];
screenshot: Screenshot;
}
const { platform, eyebrow, titleLead, titleAccent, lede, release, downloads, facts, screenshot } = Astro.props;
const releaseDate = formatReleaseDate(release.publishedAt);
---
<section class="relative overflow-hidden pt-36 pb-20 sm:pt-40">
<div class="pointer-events-none absolute top-0 left-1/2 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
<div class="relative mx-auto max-w-6xl px-6">
<nav aria-label="Breadcrumb" class="font-mono text-[11px] uppercase tracking-[0.15em] text-surface-500">
<ol class="flex flex-wrap items-center gap-2">
<li><a href="/iptvnator/" class="transition-colors hover:text-accent-400">IPTVnator</a></li>
<li aria-hidden="true">/</li>
<li><a href="/iptvnator/download/" class="transition-colors hover:text-accent-400">Download</a></li>
<li aria-hidden="true">/</li>
<li class="text-surface-300" aria-current="page">{PLATFORM_LABELS[platform]}</li>
</ol>
</nav>
<div class="mt-10 grid items-center gap-14 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div>
<div class="inline-flex items-center gap-3">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20">
<svg class="h-5 w-5 text-accent-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d={PLATFORM_ICON_PATHS[platform]} />
</svg>
</span>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">{eyebrow}</span>
</div>
<h1 class="mt-6 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
{titleLead}{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">{titleAccent}</em>
</h1>
<p class="mt-6 max-w-xl text-lg leading-relaxed text-surface-400">{lede}</p>
<div class="mt-10 flex flex-wrap gap-4">
{downloads.map((download) => <DownloadButton download={download} primary={download.recommended === true} />)}
{downloads.length === 0 && (
<a href={LATEST_RELEASE_URL} class="inline-flex items-center gap-3 rounded-xl bg-accent-500 px-6 py-4 font-semibold text-surface-950 transition-all duration-300 hover:bg-accent-400">
Download from GitHub releases
</a>
)}
</div>
<p class="mt-5 font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">
Version {release.version}
{releaseDate && <span> · Released {releaseDate}</span>}
<span> · </span>
<a href={release.url} class="text-surface-400 underline decoration-surface-600 underline-offset-4 transition-colors hover:text-accent-400">Release notes</a>
<span> · </span>
<a href={LATEST_RELEASE_URL} class="text-surface-400 underline decoration-surface-600 underline-offset-4 transition-colors hover:text-accent-400">All files</a>
</p>
<slot name="after-buttons" />
<ul class="mt-8 flex flex-wrap gap-2" aria-label="Key facts">
{facts.map((fact) => (
<li class="rounded-full border border-dashed border-surface-700/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-400">{fact}</li>
))}
</ul>
</div>
<figure class="relative">
<div class="pointer-events-none absolute -inset-6 rounded-3xl bg-accent-500/[0.05] blur-2xl"></div>
<div class="relative overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.45)]">
<img
src={screenshot.src}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
class="h-auto w-full rounded-lg"
loading="eager"
decoding="async"
/>
</div>
<figcaption class="mt-3 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-surface-600">{screenshot.alt}</figcaption>
</figure>
</div>
</div>
</section>
@@ -0,0 +1,35 @@
---
interface Props {
id?: string;
eyebrow: string;
title: string;
accent?: string;
lead?: string;
}
const { id, eyebrow, title, accent, lead } = Astro.props;
---
<section id={id} class="relative py-20">
<hr class="section-divider mb-20" />
<div class="mx-auto max-w-6xl px-6">
<div class="max-w-2xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">{eyebrow}</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
{title}
{accent && (
<Fragment>
{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">{accent}</em>
</Fragment>
)}
</h2>
{lead && <p class="mt-4 text-lg text-surface-400">{lead}</p>}
</div>
<div class="mt-12">
<slot />
</div>
</div>
</section>
@@ -0,0 +1,27 @@
---
interface FeatureItem {
title: string;
description: string;
}
interface Props {
items: FeatureItem[];
}
const { items } = Astro.props;
---
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => (
<div class="group relative overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-6 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]">
<div class="mb-4 flex items-center gap-3">
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.2em] text-accent-500/70 transition-colors duration-300 group-hover:text-accent-400">
{String(index + 1).padStart(2, '0')}
</span>
<span class="h-px flex-1 bg-gradient-to-r from-accent-500/20 to-transparent"></span>
</div>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50">{item.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">{item.description}</p>
</div>
))}
</div>
@@ -0,0 +1,23 @@
---
import { LATEST_RELEASE_URL } from '../../lib/downloads';
---
<div class="rounded-xl border border-dashed border-amber-500/35 bg-amber-500/[0.07] p-5 sm:p-6">
<div class="flex items-start gap-3">
<span class="mt-0.5 text-amber-300" aria-hidden="true">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0 3h.008M4.5 19.5h15a2 2 0 001.73-3L13.73 4.5a2 2 0 00-3.46 0L2.77 16.5a2 2 0 001.73 3z" />
</svg>
</span>
<div class="min-w-0 text-sm leading-relaxed text-surface-200">
<p class="font-semibold text-amber-200">Official downloads only</p>
<p class="mt-1.5">
IPTVnator is a player, not a TV service. It is distributed exclusively through{' '}
<a href={LATEST_RELEASE_URL} class="text-accent-300 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-200">GitHub releases</a>{' '}
and the package managers listed on this page. Websites that sell "IPTVnator subscriptions", channel packages or
"activated" copies are not affiliated with the project.{' '}
<a href="/iptvnator/blog/beware-unofficial-iptvnator-websites/" class="text-accent-300 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-200">How to recognize unofficial sites.</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,57 @@
---
import type { ResolvedDownload } from '../../lib/downloads';
import { formatAssetSize } from '../../lib/downloads';
interface PackageRow {
download: ResolvedDownload;
/** Install command shown next to the file, if any. */
command?: string;
note?: string;
}
interface Props {
title: string;
rows: PackageRow[];
}
const { title, rows } = Astro.props;
---
<div class="overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-surface-900/40">
<div class="border-b border-dashed border-surface-700/60 px-5 py-3">
<span class="font-mono text-[11px] uppercase tracking-[0.15em] text-accent-400">{title}</span>
</div>
<ul class="divide-y divide-dashed divide-surface-700/60">
{rows.map(({ download, command, note }) => {
const size = formatAssetSize(download.size);
return (
<li class="grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_auto] sm:items-center">
<div>
<p class="text-sm font-semibold text-surface-100">{download.label}</p>
<p class="mt-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">
{download.detail}
{size && <span> · {size}</span>}
</p>
{note && <p class="mt-1.5 text-xs leading-relaxed text-surface-500">{note}</p>}
</div>
<div class="min-w-0">
{command && (
<code class="block overflow-x-auto rounded-md bg-surface-950/70 px-3 py-2 font-mono text-xs text-accent-200">{command}</code>
)}
</div>
<a
href={download.url}
data-download={download.id}
rel="noopener"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-accent-500/30 bg-accent-500/10 px-4 py-2 text-[13px] font-medium text-accent-400 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/20 hover:text-accent-300"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
</li>
);
})}
</ul>
</div>
@@ -0,0 +1,41 @@
---
import { DOWNLOAD_PLATFORMS, PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGES } from '../../lib/platforms';
import type { DownloadPlatform } from '../../lib/platforms';
interface Props {
current?: DownloadPlatform;
}
const { current } = Astro.props;
const others = DOWNLOAD_PLATFORMS.filter((platform) => platform !== current);
---
<section class="relative py-20">
<hr class="section-divider mb-20" />
<div class="mx-auto max-w-6xl px-6">
<div class="flex flex-col items-start gap-8 md:flex-row md:items-center md:justify-between">
<div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Other platforms</span>
<h2 class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Also available for{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">{others.map((platform) => PLATFORM_LABELS[platform]).join(' and ')}</em>
</h2>
</div>
<div class="flex flex-wrap gap-3">
{others.map((platform) => (
<a href={PLATFORM_PAGES[platform]} class="card-dotted group flex items-center gap-3 px-5 py-3">
<svg class="h-5 w-5 text-surface-500 transition-colors duration-200 group-hover:text-accent-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d={PLATFORM_ICON_PATHS[platform]} />
</svg>
<span class="text-sm font-semibold text-surface-100">IPTVnator for {PLATFORM_LABELS[platform]}</span>
</a>
))}
<a href="/iptvnator/download/" class="card-dotted flex items-center px-5 py-3 text-sm font-semibold text-surface-300 hover:text-accent-400">
All downloads
</a>
</div>
</div>
</div>
</section>
@@ -0,0 +1,30 @@
---
import { getCollection } from 'astro:content';
import BlogPostCard from '../BlogPostCard.astro';
interface Props {
/** Blog entry ids (file slugs) in display order. */
slugs: string[];
}
const { slugs } = Astro.props;
const posts = await getCollection('blog');
const selected = slugs
.map((slug) => posts.find((post) => post.id === slug && !post.data.draft))
.filter((post): post is NonNullable<typeof post> => post !== undefined);
---
{selected.length > 0 && (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{selected.map((post) => (
<BlogPostCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
slug={post.id}
tags={post.data.tags}
heroImage={post.data.heroImage}
/>
))}
</div>
)}
@@ -0,0 +1,21 @@
---
interface SpecItem {
term: string;
value: string;
}
interface Props {
items: SpecItem[];
}
const { items } = Astro.props;
---
<dl class="grid gap-px overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-surface-700/40 sm:grid-cols-2">
{items.map((item) => (
<div class="bg-surface-950/95 px-5 py-4">
<dt class="font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">{item.term}</dt>
<dd class="mt-1.5 text-sm leading-relaxed text-surface-200">{item.value}</dd>
</div>
))}
</dl>
+68
View File
@@ -0,0 +1,68 @@
import { LATEST_RELEASE_URL, REPO_URL, formatAssetSize } from './downloads';
import type { ResolvedDownload, ResolvedRelease } from './downloads';
import { PLATFORM_LABELS } from './platforms';
import type { DownloadPlatform } from './platforms';
import { absoluteUrl } from './site';
export interface FaqEntry {
q: string;
a: string;
}
interface DownloadPageSchemaInput {
platform: DownloadPlatform;
/** schema.org `operatingSystem` text, e.g. "Windows 10, Windows 11". */
operatingSystem: string;
pageUrl: string;
description: string;
release: ResolvedRelease;
downloads: ResolvedDownload[];
faq: FaqEntry[];
}
/** SoftwareApplication + FAQPage + BreadcrumbList for one per-OS download page. */
export function buildDownloadPageSchema(input: DownloadPageSchemaInput): Array<Record<string, unknown>> {
const { platform, operatingSystem, pageUrl, description, release, downloads, faq } = input;
const primary = downloads[0];
const fileSize = primary ? formatAssetSize(primary.size) : null;
return [
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'IPTVnator',
applicationCategory: 'MultimediaApplication',
applicationSubCategory: 'IPTV player',
operatingSystem,
softwareVersion: release.version,
...(release.publishedAt ? { datePublished: release.publishedAt.toISOString().slice(0, 10) } : {}),
url: pageUrl,
downloadUrl: primary?.url ?? LATEST_RELEASE_URL,
...(fileSize ? { fileSize } : {}),
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, 'https://t.me/iptvnator'],
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: PLATFORM_LABELS[platform], item: pageUrl },
],
},
];
}
+327
View File
@@ -0,0 +1,327 @@
import rootPackage from '../../../../package.json';
import type { DownloadPlatform } from './platforms';
/**
* Latest-release resolution for the download pages.
*
* At build time the pages ask the GitHub Releases API for the latest
* *published* release so that every direct link points at an asset that
* exists, and so file sizes and the publish date can be shown. When the API
* is unreachable (offline build, rate limit, `WEBSITE_SKIP_RELEASE_FETCH=1`)
* the pages fall back to the repository version from the root `package.json`
* and the known asset naming pattern from `electron-builder.json`.
*/
export const GITHUB_REPO = '4gray/iptvnator';
export const REPO_URL = `https://github.com/${GITHUB_REPO}`;
export const RELEASES_URL = `${REPO_URL}/releases`;
export const LATEST_RELEASE_URL = `${RELEASES_URL}/latest`;
const LATEST_RELEASE_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
const FETCH_TIMEOUT_MS = 8000;
const VERSION_TAG_PATTERN = /^v(\d+\.\d+\.\d+)$/;
export interface ReleaseAsset {
name: string;
url: string;
/** Bytes, or `null` when the size is unknown (fallback mode). */
size: number | null;
}
export interface ResolvedRelease {
version: string;
tag: string;
/** Release page on GitHub. */
url: string;
publishedAt: Date | null;
assets: ReleaseAsset[];
/** Where the data came from; the fallback cannot promise that the files exist yet. */
source: 'github-api' | 'package-json';
}
export interface DownloadOption {
id: string;
platform: DownloadPlatform;
/** Short human label, e.g. "Apple Silicon". */
label: string;
/** One-line qualifier, e.g. "M1–M4 · .dmg". */
detail: string;
/** Matches the asset file name inside the release. */
matcher: RegExp;
/** Expected file name when the asset list is unavailable. */
fallbackName: (version: string) => string;
/** Preferred choice for the platform; rendered as the primary button. */
recommended?: boolean;
}
export interface ResolvedDownload extends Omit<DownloadOption, 'matcher' | 'fallbackName'> {
fileName: string;
url: string;
size: number | null;
}
export const DOWNLOAD_OPTIONS: Record<DownloadPlatform, DownloadOption[]> = {
windows: [
{
id: 'windows-x64-setup',
platform: 'windows',
label: 'Download for Windows',
detail: 'Windows 10 / 11 · 64-bit installer',
matcher: /-windows-x64-setup\.exe$/,
fallbackName: (v) => `iptvnator-${v}-windows-x64-setup.exe`,
recommended: true,
},
],
macos: [
{
id: 'mac-arm64-dmg',
platform: 'macos',
label: 'Apple Silicon',
detail: 'M1 – M4 Macs · .dmg',
matcher: /-mac-arm64\.dmg$/,
fallbackName: (v) => `iptvnator-${v}-mac-arm64.dmg`,
recommended: true,
},
{
id: 'mac-x64-dmg',
platform: 'macos',
label: 'Intel',
detail: 'Intel Macs · .dmg',
matcher: /-mac-x64\.dmg$/,
fallbackName: (v) => `iptvnator-${v}-mac-x64.dmg`,
},
],
linux: [
{
id: 'linux-x86_64-appimage',
platform: 'linux',
label: 'AppImage',
detail: 'Any distribution · x86_64',
matcher: /-linux-x86_64\.AppImage$/,
fallbackName: (v) => `iptvnator-${v}-linux-x86_64.AppImage`,
recommended: true,
},
{
id: 'linux-amd64-deb',
platform: 'linux',
label: 'Debian / Ubuntu',
detail: '.deb · amd64',
matcher: /-linux-amd64\.deb$/,
fallbackName: (v) => `iptvnator-${v}-linux-amd64.deb`,
},
{
id: 'linux-x86_64-rpm',
platform: 'linux',
label: 'Fedora / openSUSE',
detail: '.rpm · x86_64',
matcher: /-linux-x86_64\.rpm$/,
fallbackName: (v) => `iptvnator-${v}-linux-x86_64.rpm`,
},
{
id: 'linux-x64-pacman',
platform: 'linux',
label: 'Arch / Manjaro',
detail: '.pacman · x64',
matcher: /-linux-x64\.pacman$/,
fallbackName: (v) => `iptvnator-${v}-linux-x64.pacman`,
},
{
id: 'linux-x86_64-flatpak',
platform: 'linux',
label: 'Flatpak bundle',
detail: '.flatpak · x86_64',
matcher: /-linux-x86_64\.flatpak$/,
fallbackName: (v) => `iptvnator-${v}-linux-x86_64.flatpak`,
},
{
id: 'linux-amd64-snap',
platform: 'linux',
label: 'Snap',
detail: '.snap · amd64',
matcher: /-linux-amd64\.snap$/,
fallbackName: (v) => `iptvnator-${v}-linux-amd64.snap`,
},
{
id: 'linux-arm64-appimage',
platform: 'linux',
label: 'AppImage',
detail: 'arm64 (aarch64)',
matcher: /-linux-arm64\.AppImage$/,
fallbackName: (v) => `iptvnator-${v}-linux-arm64.AppImage`,
},
{
id: 'linux-arm64-deb',
platform: 'linux',
label: 'Debian / Ubuntu',
detail: '.deb · arm64',
matcher: /-linux-arm64\.deb$/,
fallbackName: (v) => `iptvnator-${v}-linux-arm64.deb`,
},
{
id: 'linux-armv7l-appimage',
platform: 'linux',
label: 'AppImage',
detail: 'armv7l (32-bit ARM)',
matcher: /-linux-armv7l\.AppImage$/,
fallbackName: (v) => `iptvnator-${v}-linux-armv7l.AppImage`,
},
{
id: 'linux-armv7l-deb',
platform: 'linux',
label: 'Debian / Ubuntu',
detail: '.deb · armv7l',
matcher: /-linux-armv7l\.deb$/,
fallbackName: (v) => `iptvnator-${v}-linux-armv7l.deb`,
},
{
id: 'linux-armhf-snap',
platform: 'linux',
label: 'Snap',
detail: '.snap · armhf',
matcher: /-linux-armhf\.snap$/,
fallbackName: (v) => `iptvnator-${v}-linux-armhf.snap`,
},
],
};
let releasePromise: Promise<ResolvedRelease> | undefined;
/** Resolves once per build; every page shares the same answer. */
export function getLatestRelease(): Promise<ResolvedRelease> {
releasePromise ??= resolveLatestRelease();
return releasePromise;
}
export function assetUrl(tag: string, fileName: string): string {
return `${RELEASES_URL}/download/${tag}/${fileName}`;
}
/**
* Downloads for one platform, in the declared order. When the release came
* from the API, options whose asset is missing are dropped so a page never
* links to a 404; the fallback assumes the full asset set.
*/
export function resolveDownloads(release: ResolvedRelease, platform: DownloadPlatform): ResolvedDownload[] {
return DOWNLOAD_OPTIONS[platform].flatMap((option) => {
const asset = release.assets.find((candidate) => option.matcher.test(candidate.name));
if (!asset) {
return [];
}
const { matcher: _matcher, fallbackName: _fallbackName, ...rest } = option;
return [{ ...rest, fileName: asset.name, url: asset.url, size: asset.size }];
});
}
export function formatAssetSize(bytes: number | null): string | null {
if (bytes === null || !Number.isFinite(bytes) || bytes <= 0) {
return null;
}
return `${Math.round(bytes / (1024 * 1024))} MB`;
}
export function formatReleaseDate(date: Date | null): string | null {
if (!date) {
return null;
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
}
async function resolveLatestRelease(): Promise<ResolvedRelease> {
const published = await fetchPublishedRelease();
return published ?? fallbackRelease();
}
async function fetchPublishedRelease(): Promise<ResolvedRelease | null> {
if (process.env.WEBSITE_SKIP_RELEASE_FETCH === '1') {
return null;
}
try {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'iptvnator-website-build',
};
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(LATEST_RELEASE_API_URL, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
warnFallback(`GitHub API answered HTTP ${response.status}`);
return null;
}
const release = normalizeRelease(await response.json());
if (!release) {
warnFallback('GitHub API response had an unexpected shape');
}
return release;
} catch (error) {
warnFallback(error instanceof Error ? error.message : String(error));
return null;
}
}
function normalizeRelease(payload: unknown): ResolvedRelease | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
const tag = typeof record.tag_name === 'string' ? record.tag_name : '';
const versionMatch = VERSION_TAG_PATTERN.exec(tag);
if (!versionMatch) {
return null;
}
const assets = Array.isArray(record.assets) ? record.assets.flatMap(normalizeAsset) : [];
if (assets.length === 0) {
return null;
}
const publishedAt = typeof record.published_at === 'string' ? new Date(record.published_at) : null;
return {
version: versionMatch[1],
tag,
url: typeof record.html_url === 'string' ? record.html_url : `${RELEASES_URL}/tag/${tag}`,
publishedAt: publishedAt && !Number.isNaN(publishedAt.getTime()) ? publishedAt : null,
assets,
source: 'github-api',
};
}
function normalizeAsset(value: unknown): ReleaseAsset[] {
if (!value || typeof value !== 'object') {
return [];
}
const record = value as Record<string, unknown>;
const name = typeof record.name === 'string' ? record.name : '';
const url = typeof record.browser_download_url === 'string' ? record.browser_download_url : '';
if (!name || !url.startsWith(`${REPO_URL}/`)) {
return [];
}
const size = typeof record.size === 'number' && Number.isFinite(record.size) ? record.size : null;
return [{ name, url, size }];
}
function fallbackRelease(): ResolvedRelease {
const version = rootPackage.version;
const tag = `v${version}`;
const assets = Object.values(DOWNLOAD_OPTIONS)
.flat()
.map((option) => {
const name = option.fallbackName(version);
return { name, url: assetUrl(tag, name), size: null };
});
return {
version,
tag,
url: `${RELEASES_URL}/tag/${tag}`,
publishedAt: null,
assets,
source: 'package-json',
};
}
function warnFallback(reason: string): void {
console.warn(`[website] Latest release lookup failed (${reason}); using package.json version ${rootPackage.version}.`);
}
+26
View File
@@ -0,0 +1,26 @@
export type DownloadPlatform = 'windows' | 'macos' | 'linux';
export const DOWNLOAD_PLATFORMS: readonly DownloadPlatform[] = ['windows', 'macos', 'linux'];
export const PLATFORM_LABELS: Record<DownloadPlatform, string> = {
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
};
/** Site-relative download page per platform (includes the GitHub Pages base). */
export const PLATFORM_PAGES: Record<DownloadPlatform, string> = {
windows: '/iptvnator/download/windows/',
macos: '/iptvnator/download/macos/',
linux: '/iptvnator/download/linux/',
};
/** 24×24 `fill="currentColor"` icon paths shared by the download CTA and the download pages. */
export const PLATFORM_ICON_PATHS: Record<DownloadPlatform, string> = {
macos:
'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z',
windows:
'M3 12V6.75l8-1.25V12H3zm0 .5h8v6.5l-8-1.25V12.5zM11.5 5.35L21 3.75V12h-9.5V5.35zm0 7.15H21v8.25l-9.5-1.6V12.5z',
linux:
'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.368 1.884 1.43.39.03.8-.066 1.109-.199.69-.3 1.286-1.065 1.09-1.745a21.03 21.03 0 01-.174-.606c-.101-.427-.2-.855-.29-1.283-.09-.428.04-.546.481-.735.463-.199.833-.338 1.073-.668.24-.33.364-.8.269-1.437-.14-.98-.689-2.072-1.329-3.069-.645-1-.34-1.665-.34-2.863.006-1.497-.898-2.987-1.792-4.048-.89-1.06-1.78-1.835-2.085-2.748-.228-.68-.098-1.401.128-2.056.112-.322.254-.631.325-.933.07-.303.072-.622-.058-.853C13.56.199 13.04 0 12.504 0z',
};
+16
View File
@@ -0,0 +1,16 @@
/**
* Canonical site location. The site is served from a GitHub Pages project
* path, so every absolute URL has to carry the `/iptvnator` base.
*/
export const SITE_ORIGIN = 'https://4gray.github.io';
export const SITE_BASE = '/iptvnator';
/** Absolute URL for a site-relative path such as `/download/windows/`. */
export function absoluteUrl(path: string): string {
return `${SITE_ORIGIN}${SITE_BASE}${path}`;
}
/** Site-relative href with the GitHub Pages base prefix. */
export function siteHref(path: string): string {
return `${SITE_BASE}${path}`;
}
+153
View File
@@ -0,0 +1,153 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import CopyCommand from '../../components/blog/CopyCommand.astro';
import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro';
import { LATEST_RELEASE_URL, REPO_URL, formatAssetSize, formatReleaseDate, getLatestRelease, resolveDownloads } from '../../lib/downloads';
import { DOWNLOAD_PLATFORMS, PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGES } from '../../lib/platforms';
import { absoluteUrl } from '../../lib/site';
const release = await getLatestRelease();
const releaseDate = formatReleaseDate(release.publishedAt);
const pageUrl = absoluteUrl('/download/');
const platformSummaries = {
windows: 'Installer for Windows 10 and 11, 64-bit.',
macos: 'Disk images for Apple Silicon and Intel, signed and notarized. Also on Homebrew.',
linux: 'AppImage, .deb, .rpm, pacman, Flatpak and Snap for x86_64 and ARM.',
} as const;
const cards = DOWNLOAD_PLATFORMS.map((platform) => {
const downloads = resolveDownloads(release, platform);
const primary = downloads.find((download) => download.recommended) ?? downloads[0];
return {
platform,
label: PLATFORM_LABELS[platform],
href: PLATFORM_PAGES[platform],
summary: platformSummaries[platform],
primaryName: primary?.fileName ?? null,
primarySize: primary ? formatAssetSize(primary.size) : null,
};
});
const title = 'Download IPTVnator – Windows, macOS and Linux';
const description =
'Download IPTVnator, the free open-source IPTV player, for Windows, macOS and Linux. Direct installers, Homebrew, Snap, AUR and a self-hosted browser version.';
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'IPTVnator',
applicationCategory: 'MultimediaApplication',
applicationSubCategory: 'IPTV player',
operatingSystem: 'Windows, macOS, Linux',
softwareVersion: release.version,
url: pageUrl,
downloadUrl: LATEST_RELEASE_URL,
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' },
description,
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: 'Download', item: pageUrl },
],
},
];
---
<BaseLayout
title={title}
description={description}
keywords={['IPTVnator download', 'IPTV player download', 'IPTV player Windows', 'IPTV player Mac', 'IPTV player Linux', 'open-source IPTV player']}
jsonLd={jsonLd}
>
<section class="relative overflow-hidden pt-36 pb-16 sm:pt-40">
<div class="pointer-events-none absolute top-0 left-1/2 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
<div class="relative mx-auto max-w-6xl px-6">
<div class="mx-auto max-w-2xl text-center">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Download</span>
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
Get <em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em>
</h1>
<p class="mt-6 text-lg leading-relaxed text-surface-400">
Pick your operating system for a direct download, install steps and the answers to the usual questions.
Every build comes from the same GitHub release.
</p>
<p class="mt-5 font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">
Version {release.version}
{releaseDate && <span> · Released {releaseDate}</span>}
<span> · </span>
<a href={release.url} class="text-surface-400 underline decoration-surface-600 underline-offset-4 transition-colors hover:text-accent-400">Release notes</a>
</p>
</div>
<div class="mt-14 grid gap-5 md:grid-cols-3">
{cards.map((card) => (
<a href={card.href} class="card-dotted group flex flex-col p-7">
<div class="flex items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-300 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35">
<svg class="h-5 w-5 text-accent-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d={PLATFORM_ICON_PATHS[card.platform]} />
</svg>
</span>
<h2 class="font-display text-xl font-bold tracking-tight text-surface-50">{card.label}</h2>
</div>
<p class="mt-4 flex-1 text-sm leading-relaxed text-surface-400">{card.summary}</p>
{card.primaryName && (
<p class="mt-5 truncate font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500" title={card.primaryName}>
{card.primarySize && <span class="text-surface-300">{card.primarySize} · </span>}
{card.primaryName}
</p>
)}
<span class="mt-4 inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors group-hover:text-accent-400">
Download for {card.label}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
</a>
))}
</div>
</div>
</section>
<section class="relative py-20">
<hr class="section-divider mb-20" />
<div class="mx-auto max-w-6xl px-6">
<div class="grid gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Package managers</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
One command <em class="font-medium text-accent-400" style="font-style: italic;">instead</em>
</h2>
<p class="mt-4 text-lg text-surface-400">
Homebrew, the Snap Store and the AUR track every release. The self-hosted browser version runs from a
single Docker image for devices that cannot install the desktop app.
</p>
<p class="mt-6 text-sm text-surface-500">
Browser version:{' '}
<a href={`${REPO_URL}/blob/master/docker/README.md`} class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 transition-colors hover:text-accent-300" rel="noopener">
self-host it with Docker
</a>
</p>
</div>
<div>
<CopyCommand title="Homebrew (macOS)" command="brew install --cask iptvnator" />
<CopyCommand title="Snap Store (Linux)" command="sudo snap install iptvnator" />
<CopyCommand title="AUR (Arch Linux)" command="yay -S iptvnator-bin" />
</div>
</div>
<div class="mt-16">
<OfficialSourcesNote />
</div>
</div>
</section>
</BaseLayout>
+308
View File
@@ -0,0 +1,308 @@
---
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 StepRail from '../../components/blog/StepRail.astro';
import TerminalBlock from '../../components/blog/TerminalBlock.astro';
import DownloadHero from '../../components/download/DownloadHero.astro';
import DownloadSection from '../../components/download/DownloadSection.astro';
import FeatureList from '../../components/download/FeatureList.astro';
import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro';
import PackageList from '../../components/download/PackageList.astro';
import PlatformSwitcher from '../../components/download/PlatformSwitcher.astro';
import RelatedPosts from '../../components/download/RelatedPosts.astro';
import SpecList from '../../components/download/SpecList.astro';
import { buildDownloadPageSchema } from '../../lib/download-schema';
import type { FaqEntry } from '../../lib/download-schema';
import { getLatestRelease, resolveDownloads } from '../../lib/downloads';
import type { ResolvedDownload } from '../../lib/downloads';
import { absoluteUrl } from '../../lib/site';
const release = await getLatestRelease();
const downloads = resolveDownloads(release, 'linux');
const byId = new Map(downloads.map((download) => [download.id, download]));
const pick = (...ids: string[]): ResolvedDownload[] =>
ids.flatMap((id) => {
const download = byId.get(id);
return download ? [download] : [];
});
const heroDownloads = pick('linux-x86_64-appimage', 'linux-amd64-deb');
const pageUrl = absoluteUrl('/download/linux/');
const title = 'Download IPTVnator for Linux – AppImage, deb, rpm, Flatpak and Snap';
const description =
'Download IPTVnator for Linux: a free, open-source IPTV player for M3U/M3U8 playlists, Xtream Codes and Stalker portals with XMLTV EPG. AppImage, .deb, .rpm, pacman, Flatpak and Snap packages for x86_64, arm64 and armv7l.';
const installCommands: Record<string, string> = {
'linux-x86_64-appimage': 'chmod +x iptvnator-*.AppImage && ./iptvnator-*.AppImage',
'linux-amd64-deb': 'sudo apt install ./iptvnator-*-linux-amd64.deb',
'linux-x86_64-rpm': 'sudo dnf install ./iptvnator-*-linux-x86_64.rpm',
'linux-x64-pacman': 'sudo pacman -U ./iptvnator-*-linux-x64.pacman',
'linux-x86_64-flatpak': 'flatpak install --user ./iptvnator-*-linux-x86_64.flatpak',
'linux-amd64-snap': 'sudo snap install iptvnator',
'linux-arm64-appimage': 'chmod +x iptvnator-*.AppImage && ./iptvnator-*.AppImage',
'linux-arm64-deb': 'sudo apt install ./iptvnator-*-linux-arm64.deb',
'linux-armv7l-appimage': 'chmod +x iptvnator-*.AppImage && ./iptvnator-*.AppImage',
'linux-armv7l-deb': 'sudo apt install ./iptvnator-*-linux-armv7l.deb',
'linux-armhf-snap': 'sudo snap install iptvnator',
};
const packageNotes: Record<string, string> = {
'linux-x86_64-appimage': 'No installation, works on any current distribution. Receives in-app update notifications.',
'linux-amd64-deb': 'Ubuntu 24.04+, Debian 12+ and derivatives. Depends on libmpv2 for the embedded MPV engine; on Ubuntu 22.04 use the AppImage.',
'linux-x86_64-rpm': 'Fedora, openSUSE, RHEL-family distributions.',
'linux-x64-pacman': 'Arch Linux, Manjaro and other pacman-based systems. The AUR package iptvnator-bin is an alternative.',
'linux-x86_64-flatpak': 'Sideloadable bundle. Needs the Flathub remote for the org.freedesktop.Platform 24.08 runtime. Embedded MPV is not available inside the sandbox.',
'linux-amd64-snap': 'Prefer the Snap Store listing; the file is provided for offline installs. Embedded MPV is not available inside the sandbox.',
'linux-arm64-appimage': 'Raspberry Pi 4/5 and other 64-bit ARM boards running a desktop.',
'linux-arm64-deb': 'Debian and Ubuntu on 64-bit ARM.',
'linux-armv7l-appimage': '32-bit ARM desktops.',
'linux-armv7l-deb': 'Debian and Ubuntu on 32-bit ARM.',
'linux-armhf-snap': 'Snap Store build for 32-bit ARM.',
};
const toRows = (ids: string[]) =>
pick(...ids).map((download) => ({
download,
command: installCommands[download.id],
note: packageNotes[download.id],
}));
const x86Rows = toRows([
'linux-x86_64-appimage',
'linux-amd64-deb',
'linux-x86_64-rpm',
'linux-x64-pacman',
'linux-x86_64-flatpak',
'linux-amd64-snap',
]);
const armRows = toRows(['linux-arm64-appimage', 'linux-arm64-deb', 'linux-armv7l-appimage', 'linux-armv7l-deb', 'linux-armhf-snap']);
const appImageSteps = [
'Download the x86_64 AppImage with the button above.',
'Make it executable: right-click › Properties › "Allow executing file as program", or run chmod +x on the file.',
'Double-click the AppImage or start it from a terminal. Nothing is installed system-wide; delete the file to remove the app.',
'Add your first source: an M3U/M3U8 file or URL, an Xtream Codes login, or a Stalker portal.',
];
const features = [
{
title: 'M3U, Xtream Codes and Stalker',
description:
'Import M3U/M3U8 playlists from a file or URL, or sign in to Xtream Codes and Stalker/Ministra portals. Live TV, movies and series land in one library with favorites, history and search.',
},
{
title: 'Program guide (EPG)',
description:
'Attach an XMLTV guide to see what is on now and next, browse the multi-channel timeline under the player, and jump into catch-up where your provider offers it.',
},
{
title: 'MPV and VLC integration',
description:
'Hand streams that the built-in players cannot decode to the mpv or vlc binary from your distribution. Playback positions and the remote control stay inside IPTVnator.',
},
{
title: 'Embedded MPV engine (experimental)',
description:
'Drives the system mpv inside the IPTVnator window on x86_64 desktops under X11 or Xwayland. Requires mpv on PATH; not available in the Flatpak and Snap sandboxes.',
},
{
title: 'Offline downloads',
description:
'Save movies and episodes from Xtream and Stalker sources to disk. The download manager resumes interrupted transfers and keeps a local library.',
},
{
title: 'Movie and series metadata',
description:
'Optional TMDB enrichment adds posters, plots, cast, ratings and trailers to movie and series details. Off by default, one switch in Settings.',
},
{
title: 'Remote control from your phone',
description:
'Open the built-in remote-control web app on your phone to switch channels, adjust volume and control playback from the couch.',
},
{
title: 'Self-hosted browser version',
description:
'Run the web app and its backend from one Docker image on a home server and open it from any browser on your network.',
},
{
title: 'No account, no tracking',
description:
'There is nothing to sign up for. Playlists and credentials stay in a local database under your home directory, and the app sends no analytics.',
},
];
const specs = [
{
term: 'Distributions',
value: 'Any current x86_64 distribution via AppImage; native packages for Debian/Ubuntu, Fedora/openSUSE and Arch/Manjaro; Flatpak and Snap.',
},
{ term: 'Architectures', value: 'x86_64, arm64 and armv7l (AppImage and .deb); armhf and amd64 (Snap).' },
{ term: 'Display server', value: 'X11, or Wayland with Xwayland. Launchers start the app with --ozone-platform=x11.' },
{
term: '.deb dependencies (x86_64)',
value: 'libmpv2, libegl1, libgl1, libgbm1. Available on Ubuntu 24.04+ and Debian 12+.',
},
{ term: 'Account', value: 'None. IPTVnator never asks you to register.' },
{
term: 'Content',
value: 'Bring your own playlist or provider login. IPTVnator ships without channels.',
},
];
const faq: FaqEntry[] = [
{
q: 'Which Linux package should I choose?',
a: 'Take the AppImage if you want a single file that runs on any distribution without installation and can notify you about updates. Take the .deb, .rpm or pacman package if you want the app in your application menu and managed by your package manager. Flatpak and Snap isolate the app in a sandbox, which also means the embedded MPV engine is unavailable there.',
},
{
q: 'Is IPTVnator on Flathub?',
a: 'Not yet. A Flatpak bundle is attached to every release and installs with "flatpak install --user" as long as the Flathub remote is configured, because the bundle needs the org.freedesktop.Platform 24.08 runtime. The Snap Store listing is official and maintained by the project.',
},
{
q: 'The app exits with a chrome-sandbox error. What now?',
a: 'Some .deb and .rpm installations need the sandbox helper fixed once: run "sudo chown root:root chrome-sandbox" and "sudo chmod 4755 chrome-sandbox" inside the installation directory. As a fallback, start the app with the --no-sandbox flag.',
},
{
q: 'Does it work on Wayland?',
a: 'Yes, through Xwayland. Every launcher starts IPTVnator with --ozone-platform=x11, and GNOME and KDE ship Xwayland by default. If the app exits with Ozone or Wayland errors, start it manually with "iptvnator --ozone-platform=x11".',
},
{
q: 'Does IPTVnator run on a Raspberry Pi or other ARM boards?',
a: 'Yes. arm64 and armv7l AppImage and .deb builds are attached to every release, and the Snap Store carries an armhf build. The web players and external MPV/VLC work there; the embedded MPV engine is x86_64 only.',
},
{
q: 'Can I run IPTVnator on a home server instead of installing it?',
a: 'Yes. The Docker image builds the browser version and its backend into one container; the README in the docker directory has the compose file. The browser version does not include the desktop-only features such as external players, embedded MPV and the download manager.',
},
{
q: 'Does IPTVnator come with channels or playlists?',
a: 'No. IPTVnator is a player. You add your own M3U/M3U8 playlist, Xtream Codes login or Stalker portal from your provider. Any website selling "IPTVnator subscriptions" or channel packages is unrelated to this project.',
},
];
const jsonLd = buildDownloadPageSchema({
platform: 'linux',
operatingSystem: 'Linux',
pageUrl,
description,
release,
downloads: heroDownloads.length > 0 ? heroDownloads : downloads,
faq,
});
---
<BaseLayout
title={title}
description={description}
keywords={[
'IPTVnator Linux',
'IPTV player Linux',
'IPTV player Ubuntu',
'IPTV player Fedora',
'IPTV player Arch Linux',
'AppImage IPTV player',
'Snap IPTV player',
'Flatpak IPTV player',
'M3U player Linux',
'Xtream Codes player Linux',
'Stalker portal player Linux',
'open-source IPTV player',
]}
jsonLd={jsonLd}
>
<DownloadHero
platform="linux"
eyebrow="Download · Linux"
titleLead="IPTVnator for"
titleAccent="Linux"
lede="A free, open-source IPTV player packaged for every major Linux distribution. Load M3U/M3U8 playlists, sign in to Xtream Codes providers or Stalker portals, and watch live TV, movies and series with a full program guide. Pick the AppImage for a no-install start or a native package for your distribution."
release={release}
downloads={heroDownloads}
facts={['x86_64 · arm64 · armv7l', 'AppImage · deb · rpm · pacman', 'Flatpak & Snap', 'Free & open source']}
screenshot={{
src: '/iptvnator/screenshots/multi-epg-view.webp',
alt: 'Multi-channel program guide in IPTVnator',
width: 2180,
height: 1448,
}}
/>
<DownloadSection id="packages" eyebrow="Packages" title="Every format," accent="one release" lead="All files below are built from the same commit and published together on GitHub. Choose the one your distribution prefers.">
<div class="space-y-8">
<PackageList title="x86_64" rows={x86Rows} />
<PackageList title="ARM" rows={armRows} />
</div>
</DownloadSection>
<DownloadSection id="install" eyebrow="Installation" title="AppImage in" accent="three steps" lead="The fastest way to try IPTVnator on any distribution. Native packages install with the commands listed above.">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-start">
<StepRail title="Run the AppImage" steps={appImageSteps} />
<div>
<CopyCommand title="Snap Store" command="sudo snap install iptvnator" />
<CopyCommand title="Arch User Repository" command="yay -S iptvnator-bin" />
<TerminalBlock title="Gentoo (gentoo-zh overlay)">sudo eselect repository enable gentoo-zh
sudo emerge --sync gentoo-zh
sudo emerge iptvnator-bin</TerminalBlock>
</div>
</div>
</DownloadSection>
<DownloadSection id="notes" eyebrow="Good to know" title="Linux" accent="specifics">
<div class="grid gap-6 lg:grid-cols-2">
<Alert type="info" title="Wayland desktops">
<p>
Launchers start IPTVnator with <code>--ozone-platform=x11</code>, so Wayland sessions need Xwayland, which GNOME and KDE
ship by default. If the app exits with Ozone or Wayland errors, start it with{' '}
<code>iptvnator --ozone-platform=x11</code>.
</p>
</Alert>
<Alert type="info" title="Embedded MPV engine">
<p>
The experimental engine drives your distribution's <code>mpv</code> binary inside the app window on x86_64 under X11 or
Xwayland. Install mpv first (for example <code>sudo apt install mpv</code>) and opt in under Settings › Playback. The
Flatpak and Snap sandboxes do not expose the host mpv, so the engine stays unavailable there.
</p>
</Alert>
<Alert type="warning" title="chrome-sandbox error on some .deb / .rpm systems">
<p>
If the app aborts with "The SUID sandbox helper binary was found, but is not configured correctly", run{' '}
<code>sudo chown root:root chrome-sandbox</code> and <code>sudo chmod 4755 chrome-sandbox</code> in the
installation directory once, or start the app with <code>--no-sandbox</code>.
</p>
</Alert>
<Alert type="info" title="Ubuntu 22.04 and older Debian">
<p>
The x86_64 <code>.deb</code> depends on <code>libmpv2</code>, which Ubuntu 22.04 does not ship. Use the AppImage there;
it bundles everything it needs.
</p>
</Alert>
</div>
</DownloadSection>
<DownloadSection id="features" eyebrow="On Linux" title="What you get" accent="on your desktop" lead="The complete desktop feature set. Everything below works in the native Linux packages.">
<FeatureList items={features} />
</DownloadSection>
<DownloadSection id="requirements" eyebrow="Requirements" title="What you" accent="need">
<SpecList items={specs} />
</DownloadSection>
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="IPTVnator on Linux">
<div class="max-w-3xl">
<FaqAccordion items={faq} />
</div>
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
</DownloadSection>
<PlatformSwitcher current="linux" />
</BaseLayout>
+217
View File
@@ -0,0 +1,217 @@
---
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 StepRail from '../../components/blog/StepRail.astro';
import DownloadHero from '../../components/download/DownloadHero.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 { buildDownloadPageSchema } from '../../lib/download-schema';
import type { FaqEntry } from '../../lib/download-schema';
import { formatAssetSize, getLatestRelease, resolveDownloads } from '../../lib/downloads';
import { absoluteUrl } from '../../lib/site';
const release = await getLatestRelease();
const downloads = resolveDownloads(release, 'macos');
const appleSilicon = downloads.find((download) => download.id === 'mac-arm64-dmg');
const dmgSize = appleSilicon ? formatAssetSize(appleSilicon.size) : null;
const pageUrl = absoluteUrl('/download/macos/');
const title = 'Download IPTVnator for macOS – Free Open-Source IPTV Player for Mac';
const description =
'Download IPTVnator for Mac: a free, open-source IPTV player for Apple Silicon and Intel Macs. M3U/M3U8 playlists, Xtream Codes, Stalker portals and XMLTV EPG. Signed and notarized builds, also available through Homebrew.';
const installSteps = [
'Download the .dmg for your Mac (Apple Silicon or Intel) with the buttons above, or run the Homebrew command on the right.',
'Open the .dmg and drag IPTVnator into the Applications folder.',
'Launch IPTVnator from Applications or Spotlight. Current builds are signed and notarized by Apple, so no "unidentified developer" workaround is needed.',
'Add your first source: an M3U/M3U8 file or URL, an Xtream Codes login, or a Stalker portal.',
];
const features = [
{
title: 'M3U, Xtream Codes and Stalker',
description:
'Import M3U/M3U8 playlists from a file or URL, or sign in to Xtream Codes and Stalker/Ministra portals. Live TV, movies and series land in one library with favorites, history and search.',
},
{
title: 'Program guide (EPG)',
description:
'Attach an XMLTV guide to see what is on now and next, browse the multi-channel timeline under the player, and jump into catch-up where your provider offers it.',
},
{
title: 'Picture-in-picture',
description:
'Keep a channel floating above other apps with the picture-in-picture button of the built-in players while you work in another window.',
},
{
title: 'MPV and VLC integration',
description:
'Hand streams that the built-in players cannot decode to MPV or VLC installed on your Mac. Playback positions and the remote control stay inside IPTVnator.',
},
{
title: 'Embedded MPV engine (experimental)',
description:
'An optional engine that renders mpv inside the IPTVnator window, with a native path for Apple Silicon. Opt in under Settings › Playback.',
},
{
title: 'Offline downloads',
description:
'Save movies and episodes from Xtream and Stalker sources to disk. The download manager resumes interrupted transfers and keeps a local library.',
},
{
title: 'Movie and series metadata',
description:
'Optional TMDB enrichment adds posters, plots, cast, ratings and trailers to movie and series details. Off by default, one switch in Settings.',
},
{
title: 'Remote control from your phone',
description:
'Open the built-in remote-control web app on your phone to switch channels, adjust volume and control playback from the couch.',
},
{
title: 'Signed, notarized, self-updating',
description:
'Releases are code-signed and notarized by Apple, and IPTVnator tells you when a new version is available. Homebrew users can upgrade with brew.',
},
];
const specs = [
{ term: 'Operating system', value: 'macOS 12 Monterey or later' },
{
term: 'Chips',
value: 'Native builds for Apple Silicon (M1 to M4) and for Intel Macs. No Rosetta required.',
},
{ term: 'Download size', value: dmgSize ? `${dmgSize} disk image` : 'About 150 MB disk image' },
{ term: 'Package manager', value: 'Homebrew cask "iptvnator" installs the same signed build.' },
{ term: 'Account', value: 'None. IPTVnator never asks you to register.' },
{
term: 'Content',
value: 'Bring your own playlist or provider login. IPTVnator ships without channels.',
},
];
const faq: FaqEntry[] = [
{
q: 'Apple Silicon or Intel: which download do I need?',
a: 'Open the Apple menu and choose "About This Mac". If it lists a chip such as Apple M1, M2, M3 or M4, take the Apple Silicon build. If it lists an Intel processor, take the Intel build. The Apple Silicon build runs natively and does not need Rosetta.',
},
{
q: 'Is IPTVnator on the Mac App Store?',
a: 'No. Download the disk image from GitHub releases or install it with Homebrew using "brew install --cask iptvnator". Both deliver the same signed and notarized build.',
},
{
q: 'macOS says the app is damaged and cannot be opened. What now?',
a: 'That message came from older, unsigned builds. Releases since v0.20 (April 2026) are code-signed and notarized by Apple and open normally. Update to the current version. If you must keep an older build, remove the quarantine flag as described in the linked blog post.',
},
{
q: 'Does IPTVnator come with channels or playlists?',
a: 'No. IPTVnator is a player. You add your own M3U/M3U8 playlist, Xtream Codes login or Stalker portal from your provider. Any website selling "IPTVnator subscriptions" or channel packages is unrelated to this project.',
},
{
q: 'Which macOS versions are supported?',
a: 'macOS 12 Monterey and later, on both Apple Silicon and Intel Macs. Older macOS versions cannot run the current runtime and receive no new builds.',
},
{
q: 'Can I use MPV or VLC on the Mac?',
a: 'Yes. Install MPV (for example with "brew install mpv") or VLC, choose it under Settings › Playback, and IPTVnator launches it for every stream while keeping track of what you watched. The experimental embedded MPV engine additionally plays inside the IPTVnator window.',
},
{
q: 'How do I update IPTVnator on a Mac?',
a: 'IPTVnator checks for new releases when it starts and shows a notification with an install button. Homebrew users can run "brew upgrade --cask iptvnator" instead.',
},
];
const jsonLd = buildDownloadPageSchema({
platform: 'macos',
operatingSystem: 'macOS 12 Monterey or later',
pageUrl,
description,
release,
downloads,
faq,
});
---
<BaseLayout
title={title}
description={description}
keywords={[
'IPTVnator macOS',
'IPTVnator Mac',
'IPTV player Mac',
'IPTV player macOS',
'IPTV player Apple Silicon',
'M3U player Mac',
'Xtream Codes player Mac',
'Stalker portal player Mac',
'open-source IPTV player',
]}
jsonLd={jsonLd}
>
<DownloadHero
platform="macos"
eyebrow="Download · macOS"
titleLead="IPTVnator for"
titleAccent="macOS"
lede="A free, open-source IPTV player for Apple Silicon and Intel Macs. Load M3U/M3U8 playlists, sign in to Xtream Codes providers or Stalker portals, and watch live TV, movies and series with a full program guide. Builds are signed and notarized, so they open like any other Mac app."
release={release}
downloads={downloads}
facts={['macOS 12 Monterey+', 'Apple Silicon & Intel', 'Signed & notarized', 'Free & open source']}
screenshot={{
src: '/iptvnator/screenshots/dashboard-with-content.webp',
alt: 'IPTVnator dashboard with recently watched content and favorites',
width: 2072,
height: 1602,
}}
>
<p slot="after-buttons" class="mt-4 max-w-xl text-sm leading-relaxed text-surface-500">
Not sure which build? Open the Apple menu › About This Mac. "Chip: Apple M…" means Apple Silicon, "Processor: Intel…" means Intel.
</p>
</DownloadHero>
<DownloadSection id="install" eyebrow="Installation" title="Drag, drop," accent="done" lead="A standard disk image, or one Homebrew command. Either way you get the same signed and notarized app.">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-start">
<StepRail title="Install on macOS" steps={installSteps} />
<div>
<CopyCommand title="Homebrew" command="brew install --cask iptvnator" />
<Alert type="info" title='Older builds and the "app is damaged" message'>
<p>
Releases before v0.20 (April 2026) were not code-signed, and macOS could refuse to open them with
"IPTVnator is damaged and can't be opened". If you still run one of those builds, update to the current
release or follow the one-command fix in{' '}
<a href="/iptvnator/blog/macos-arm-app-damaged-fix/" class="text-accent-300 underline decoration-accent-500/30 underline-offset-4">this post</a>.
</p>
</Alert>
</div>
</div>
</DownloadSection>
<DownloadSection id="features" eyebrow="On macOS" title="What you get" accent="on your Mac" lead="The complete desktop feature set. Everything below works in the macOS build without add-ons.">
<FeatureList items={features} />
</DownloadSection>
<DownloadSection id="requirements" eyebrow="Requirements" title="What you" accent="need">
<SpecList items={specs} />
</DownloadSection>
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="IPTVnator on macOS">
<div class="max-w-3xl">
<FaqAccordion items={faq} />
</div>
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['macos-arm-app-damaged-fix', 'embedded-mpv-macos-experiment', 'why-external-players-help']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
</DownloadSection>
<PlatformSwitcher current="macos" />
</BaseLayout>
@@ -0,0 +1,238 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Alert from '../../components/blog/Alert.astro';
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
import StepRail from '../../components/blog/StepRail.astro';
import DownloadHero from '../../components/download/DownloadHero.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 { buildDownloadPageSchema } from '../../lib/download-schema';
import type { FaqEntry } from '../../lib/download-schema';
import { LATEST_RELEASE_URL, formatAssetSize, getLatestRelease, resolveDownloads } from '../../lib/downloads';
import { absoluteUrl } from '../../lib/site';
const release = await getLatestRelease();
const downloads = resolveDownloads(release, 'windows');
const installer = downloads[0];
const installerSize = installer ? formatAssetSize(installer.size) : null;
const pageUrl = absoluteUrl('/download/windows/');
const title = 'Download IPTVnator for Windows – Free Open-Source IPTV Player';
const description =
'Download IPTVnator for Windows 10 and 11: a free, open-source IPTV player for M3U/M3U8 playlists, Xtream Codes and Stalker portals with EPG, VOD and MPV/VLC support. Direct link to the latest installer.';
const installSteps = [
`Download the installer (${installer?.fileName ?? 'iptvnator-<version>-windows-x64-setup.exe'}) with the button above. It is a single 64-bit setup file.`,
'Run the installer. It installs IPTVnator for the current user and adds a Start menu entry and a desktop shortcut.',
'If Windows SmartScreen shows "Windows protected your PC", click "More info" and then "Run anyway". The note below explains why this prompt appears.',
'Start IPTVnator and add your first source: an M3U/M3U8 file or URL, an Xtream Codes login, or a Stalker portal.',
];
const features = [
{
title: 'M3U, Xtream Codes and Stalker',
description:
'Import M3U/M3U8 playlists from a file or URL, or sign in to Xtream Codes and Stalker/Ministra portals. Live TV, movies and series land in one library with favorites, history and search.',
},
{
title: 'Program guide (EPG)',
description:
'Attach an XMLTV guide to see what is on now and next, browse the multi-channel timeline under the player, and jump into catch-up where your provider offers it.',
},
{
title: 'MPV and VLC integration',
description:
'Hand streams that the built-in players cannot decode to MPV or VLC installed on your PC. Playback positions and the remote control stay inside IPTVnator.',
},
{
title: 'Embedded MPV engine (experimental)',
description:
'An optional engine that renders mpv directly inside the IPTVnator window for streams the web players struggle with. Opt in under Settings › Playback.',
},
{
title: 'Offline downloads',
description:
'Save movies and episodes from Xtream and Stalker sources to disk. The download manager resumes interrupted transfers and keeps a local library.',
},
{
title: 'Movie and series metadata',
description:
'Optional TMDB enrichment adds posters, plots, cast, ratings and trailers to movie and series details. Off by default, one switch in Settings.',
},
{
title: 'Remote control from your phone',
description:
'Open the built-in remote-control web app on your phone to switch channels, adjust volume and control playback from the couch.',
},
{
title: 'Updates built in',
description:
'IPTVnator checks GitHub releases on start and tells you when a new version is available, so you never have to come back to this page.',
},
{
title: 'No account, no tracking',
description:
'There is nothing to sign up for. Playlists and credentials stay on your PC in a local database, and the app sends no analytics.',
},
];
const specs = [
{ term: 'Operating system', value: 'Windows 10 or Windows 11' },
{
term: 'Architecture',
value: '64-bit (x64). Windows 11 on ARM runs the x64 build through its built-in emulation.',
},
{ term: 'Download size', value: installerSize ? `${installerSize} installer` : 'About 200 MB installer' },
{ term: 'Account', value: 'None. IPTVnator never asks you to register.' },
{
term: 'Content',
value: 'Bring your own playlist or provider login. IPTVnator ships without channels.',
},
{ term: 'License', value: 'MIT. The full source code is on GitHub.' },
];
const faq: FaqEntry[] = [
{
q: 'Is IPTVnator free on Windows?',
a: 'Yes. IPTVnator is open-source software released under the MIT license. There is no paid tier, no trial and no advertising. If it saves you time, you can support development through GitHub Sponsors or Ko-fi.',
},
{
q: 'Does IPTVnator come with channels or playlists?',
a: 'No. IPTVnator is a player. You add your own M3U/M3U8 playlist, Xtream Codes login or Stalker portal from your provider. Any website selling "IPTVnator subscriptions" or channel packages is unrelated to this project.',
},
{
q: 'Why does Windows show "Windows protected your PC"?',
a: 'The installer is not signed with a commercial code-signing certificate, so SmartScreen treats it as an unknown publisher rather than a known one. Choose "More info" and then "Run anyway", but only for a file you downloaded from the official GitHub releases page.',
},
{
q: 'Is there a portable or 32-bit version?',
a: 'No. IPTVnator for Windows ships as a 64-bit installer only. If you cannot install software on the machine, the self-hosted browser version runs from a Docker container on any device on your network.',
},
{
q: 'Which formats and sources work on Windows?',
a: 'M3U and M3U8 playlists from local files or URLs, the Xtream Codes API, Stalker/Ministra portals and XMLTV program guides. The built-in players handle HLS, MPEG-TS and DASH streams, including ClearKey-protected DASH, and anything else can be handed to MPV or VLC.',
},
{
q: 'How do I update IPTVnator on Windows?',
a: 'IPTVnator checks for new releases when it starts and shows a notification with an install button. You can also download the latest installer from this page and run it over the existing installation; your playlists and settings are kept.',
},
{
q: 'Does it run on Windows 7, 8 or 8.1?',
a: 'No. The current runtime requires Windows 10 or later. Older Windows versions are not supported and receive no builds.',
},
];
const jsonLd = buildDownloadPageSchema({
platform: 'windows',
operatingSystem: 'Windows 10, Windows 11',
pageUrl,
description,
release,
downloads,
faq,
});
---
<BaseLayout
title={title}
description={description}
keywords={[
'IPTVnator Windows',
'IPTV player Windows',
'IPTV player Windows 11',
'IPTV player Windows 10',
'M3U player Windows',
'Xtream Codes player Windows',
'Stalker portal player Windows',
'open-source IPTV player',
]}
jsonLd={jsonLd}
>
<DownloadHero
platform="windows"
eyebrow="Download · Windows"
titleLead="IPTVnator for"
titleAccent="Windows"
lede="A free, open-source IPTV player for Windows 10 and 11. Load M3U/M3U8 playlists, sign in to Xtream Codes providers or Stalker portals, and watch live TV, movies and series with a full program guide. No account, no subscription, no bundled playlists."
release={release}
downloads={downloads}
facts={['Windows 10 / 11', '64-bit installer', 'Free & open source', 'MIT license']}
screenshot={{
src: '/iptvnator/screenshots/screenshot-player.webp',
alt: 'Live channels with the inline player and program guide in IPTVnator',
width: 2442,
height: 1806,
}}
/>
<DownloadSection id="install" eyebrow="Installation" title="Up and running in" accent="two minutes" lead="One installer, no configuration wizard. The app is ready as soon as you add a playlist or a provider login.">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-start">
<StepRail title="Install on Windows" steps={installSteps} />
<Alert type="warning" title="Why Windows SmartScreen may warn you">
<p>
The Windows installer is not signed with a commercial code-signing certificate, so SmartScreen can flag it as an
unrecognized app on first launch. This is a reputation check, not a malware detection. Accept the prompt only for
installers you downloaded from the{' '}
<a href={LATEST_RELEASE_URL} class="text-accent-300 underline decoration-accent-500/30 underline-offset-4">official GitHub releases page</a>,
never for copies that arrive through other download sites.
</p>
</Alert>
</div>
</DownloadSection>
<DownloadSection id="features" eyebrow="On Windows" title="What you get" accent="on your PC" lead="The complete desktop feature set. Everything below works in the Windows build without add-ons.">
<FeatureList items={features} />
</DownloadSection>
<DownloadSection id="external-players" eyebrow="Playback" title="Built-in players, plus" accent="MPV and VLC">
<div class="grid gap-8 lg:grid-cols-2 lg:items-center">
<div class="space-y-4 text-base leading-relaxed text-surface-400">
<p>
IPTVnator ships three web playback engines (HTML5 with hls.js, Video.js and ArtPlayer) that cover HLS, MPEG-TS
and DASH streams. Some providers send codecs that browsers refuse to decode. For those, install{' '}
<a href="https://mpv.io/installation/" class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300" rel="noopener">MPV</a>{' '}
or{' '}
<a href="https://www.videolan.org/vlc/download-windows.html" class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300" rel="noopener">VLC</a>,
pick it under Settings › Playback, and IPTVnator launches it for every stream while keeping your library, favorites
and remote control in the app.
</p>
<p>
The experimental embedded MPV engine goes one step further and renders mpv inside the IPTVnator window itself.
It is opt-in and can be switched off again at any time.
</p>
</div>
<figure class="overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2">
<img
src="/iptvnator/screenshots/external-player-support-mpv.webp"
alt="Playback settings with MPV selected as the external player"
class="h-auto w-full rounded-lg"
loading="lazy"
decoding="async"
/>
</figure>
</div>
</DownloadSection>
<DownloadSection id="requirements" eyebrow="Requirements" title="What you" accent="need">
<SpecList items={specs} />
</DownloadSection>
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="IPTVnator on Windows">
<div class="max-w-3xl">
<FaqAccordion items={faq} />
</div>
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
</DownloadSection>
<PlatformSwitcher current="windows" />
</BaseLayout>
+3 -1
View File
@@ -8,7 +8,9 @@ import DownloadCTA from '../components/DownloadCTA.astro';
import SupportSection from '../components/SupportSection.astro';
import FeaturedBlogPost from '../components/FeaturedBlogPost.astro';
import BlogPostCard from '../components/BlogPostCard.astro';
import { getLatestRelease } from '../lib/downloads';
const release = await getLatestRelease();
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const allPosts = (await getCollection('blog'))
@@ -35,7 +37,7 @@ const homeSchema = [
name: 'IPTVnator',
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux, Web',
softwareVersion: '0.20.0',
softwareVersion: release.version,
url: 'https://4gray.github.io/iptvnator/',
downloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
author: {
@@ -0,0 +1,124 @@
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Structural checks for the per-OS download pages in the built website.
* The build resolves the latest release from the GitHub API and falls back to
* package.json, so assertions accept any semver version but insist on direct
* asset links, canonical URLs, structured data and internal linking.
*/
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const SITE = 'https://4gray.github.io/iptvnator';
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
const assetLink = (suffix) =>
new RegExp(
`https://github\\.com/4gray/iptvnator/releases/download/v\\d+\\.\\d+\\.\\d+/iptvnator-\\d+\\.\\d+\\.\\d+${suffix}`
);
const PAGES = {
windows: {
path: 'download/windows/index.html',
label: 'Windows',
assets: ['-windows-x64-setup\\.exe'],
operatingSystem: /Windows 10/,
},
macos: {
path: 'download/macos/index.html',
label: 'macOS',
assets: ['-mac-arm64\\.dmg', '-mac-x64\\.dmg'],
operatingSystem: /macOS/,
},
linux: {
path: 'download/linux/index.html',
label: 'Linux',
assets: [
'-linux-x86_64\\.AppImage',
'-linux-amd64\\.deb',
'-linux-x86_64\\.rpm',
'-linux-x64\\.pacman',
'-linux-x86_64\\.flatpak',
'-linux-arm64\\.AppImage',
],
operatingSystem: /Linux/,
},
};
function extractJsonLd(html) {
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
return blocks.flatMap((match) => JSON.parse(match[1]));
}
for (const [platform, page] of Object.entries(PAGES)) {
test(`${platform} download page: title, canonical and direct asset links`, async () => {
const html = await readDist(page.path);
assert.match(html, new RegExp(`<title>[^<]*IPTVnator for ${page.label}[^<]*</title>`));
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/download/${platform}/"`));
assert.match(html, /<meta name="robots" content="index, follow/);
for (const suffix of page.assets) {
assert.match(html, assetLink(suffix), `Expected a direct link to the ${suffix} asset.`);
}
});
test(`${platform} download page: structured data`, async () => {
const html = await readDist(page.path);
const schema = extractJsonLd(html);
const app = schema.find((entry) => entry['@type'] === 'SoftwareApplication');
assert.ok(app, 'Expected a SoftwareApplication entry.');
assert.match(String(app.operatingSystem), page.operatingSystem);
assert.match(String(app.softwareVersion), /^\d+\.\d+\.\d+$/);
assert.match(String(app.downloadUrl), /^https:\/\/github\.com\/4gray\/iptvnator\/releases\//);
assert.equal(app.isAccessibleForFree, true);
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
assert.ok(faq, 'Expected a FAQPage entry.');
assert.ok(faq.mainEntity.length >= 5, 'Expected at least five FAQ questions.');
const breadcrumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
assert.ok(breadcrumbs, 'Expected a BreadcrumbList entry.');
assert.equal(breadcrumbs.itemListElement.at(-1).item, `${SITE}/download/${platform}/`);
});
test(`${platform} download page: links to the other platforms and the hub`, async () => {
const html = await readDist(page.path);
for (const other of Object.keys(PAGES).filter((candidate) => candidate !== platform)) {
assert.match(html, new RegExp(`href="/iptvnator/download/${other}/"`));
}
assert.match(html, /href="\/iptvnator\/download\/"/);
assert.match(html, /href="\/iptvnator\/blog\/beware-unofficial-iptvnator-websites\/"/);
});
}
test('download hub links to every platform page', async () => {
const html = await readDist('download/index.html');
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/download/"`));
for (const platform of Object.keys(PAGES)) {
assert.match(html, new RegExp(`href="/iptvnator/download/${platform}/"`));
}
assert.match(html, /brew install --cask iptvnator/);
assert.match(html, /sudo snap install iptvnator/);
});
test('homepage download cards point at the platform pages', async () => {
const html = await readDist('index.html');
for (const platform of Object.keys(PAGES)) {
assert.match(html, new RegExp(`href="/iptvnator/download/${platform}/"`));
}
const schema = extractJsonLd(html);
const app = schema.find((entry) => entry['@type'] === 'SoftwareApplication');
assert.match(String(app.softwareVersion), /^\d+\.\d+\.\d+$/, 'Homepage schema should carry the resolved version.');
});
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/']) {
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
}
});