From 078bd07d9425c2e46fe9a6918c9d0675f5dbdb57 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 3 Sep 2026 13:08:44 +0200 Subject: [PATCH] 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 --- .github/workflows/deploy-website.yml | 3 + CLAUDE.md | 2 +- apps/website/README.md | 36 ++ apps/website/project.json | 2 +- apps/website/public/llms.txt | 4 + apps/website/src/components/DownloadCTA.astro | 26 +- apps/website/src/components/FeatureGrid.astro | 2 +- apps/website/src/components/Header.astro | 2 +- .../components/download/DownloadButton.astro | 29 ++ .../components/download/DownloadHero.astro | 107 ++++++ .../components/download/DownloadSection.astro | 35 ++ .../src/components/download/FeatureList.astro | 27 ++ .../download/OfficialSourcesNote.astro | 23 ++ .../src/components/download/PackageList.astro | 57 +++ .../download/PlatformSwitcher.astro | 41 +++ .../components/download/RelatedPosts.astro | 30 ++ .../src/components/download/SpecList.astro | 21 ++ apps/website/src/lib/download-schema.ts | 68 ++++ apps/website/src/lib/downloads.ts | 327 ++++++++++++++++++ apps/website/src/lib/platforms.ts | 26 ++ apps/website/src/lib/site.ts | 16 + apps/website/src/pages/download/index.astro | 153 ++++++++ apps/website/src/pages/download/linux.astro | 308 +++++++++++++++++ apps/website/src/pages/download/macos.astro | 217 ++++++++++++ apps/website/src/pages/download/windows.astro | 238 +++++++++++++ apps/website/src/pages/index.astro | 4 +- tools/testing/website-download-pages.test.mjs | 124 +++++++ 27 files changed, 1906 insertions(+), 22 deletions(-) create mode 100644 apps/website/src/components/download/DownloadButton.astro create mode 100644 apps/website/src/components/download/DownloadHero.astro create mode 100644 apps/website/src/components/download/DownloadSection.astro create mode 100644 apps/website/src/components/download/FeatureList.astro create mode 100644 apps/website/src/components/download/OfficialSourcesNote.astro create mode 100644 apps/website/src/components/download/PackageList.astro create mode 100644 apps/website/src/components/download/PlatformSwitcher.astro create mode 100644 apps/website/src/components/download/RelatedPosts.astro create mode 100644 apps/website/src/components/download/SpecList.astro create mode 100644 apps/website/src/lib/download-schema.ts create mode 100644 apps/website/src/lib/downloads.ts create mode 100644 apps/website/src/lib/platforms.ts create mode 100644 apps/website/src/lib/site.ts create mode 100644 apps/website/src/pages/download/index.astro create mode 100644 apps/website/src/pages/download/linux.astro create mode 100644 apps/website/src/pages/download/macos.astro create mode 100644 apps/website/src/pages/download/windows.astro create mode 100644 tools/testing/website-download-pages.test.mjs diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index a730497af..f7e1b2c52 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index cc0366b65..e0fc47973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/website/README.md b/apps/website/README.md index 031816021..a9f39cba8 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -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 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. diff --git a/apps/website/project.json b/apps/website/project.json index 5b07c6306..08e54dc19 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -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": { diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index cb6148c39..9bbcca9ed 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -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 diff --git a/apps/website/src/components/DownloadCTA.astro b/apps/website/src/components/DownloadCTA.astro index af8de6ce3..40b8c8b51 100644 --- a/apps/website/src/components/DownloadCTA.astro +++ b/apps/website/src/components/DownloadCTA.astro @@ -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' }, diff --git a/apps/website/src/components/FeatureGrid.astro b/apps/website/src/components/FeatureGrid.astro index 9ab0018e5..c12eaa6ad 100644 --- a/apps/website/src/components/FeatureGrid.astro +++ b/apps/website/src/components/FeatureGrid.astro @@ -41,7 +41,7 @@ const carouselFeatures = [ { icon: '', title: 'Multi-Language', - description: 'Available in 16 languages with community-contributed translations.', + description: 'Available in 19 languages with community-contributed translations.', }, { icon: '', diff --git a/apps/website/src/components/Header.astro b/apps/website/src/components/Header.astro index 6e6de962d..f144cf2c0 100644 --- a/apps/website/src/components/Header.astro +++ b/apps/website/src/components/Header.astro @@ -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/' }, ]; --- diff --git a/apps/website/src/components/download/DownloadButton.astro b/apps/website/src/components/download/DownloadButton.astro new file mode 100644 index 000000000..27b93a3c5 --- /dev/null +++ b/apps/website/src/components/download/DownloadButton.astro @@ -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'; +--- + + + + + {download.label} + {detail} + + diff --git a/apps/website/src/components/download/DownloadHero.astro b/apps/website/src/components/download/DownloadHero.astro new file mode 100644 index 000000000..0f34155a3 --- /dev/null +++ b/apps/website/src/components/download/DownloadHero.astro @@ -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); +--- + +
+
+ +
+ + +
+
+
+ + + + {eyebrow} +
+ +

+ {titleLead}{' '} + {titleAccent} +

+ +

{lede}

+ +
+ {downloads.map((download) => )} + {downloads.length === 0 && ( + + Download from GitHub releases + + )} +
+ +

+ Version {release.version} + {releaseDate && · Released {releaseDate}} + · + Release notes + · + All files +

+ + + +
    + {facts.map((fact) => ( +
  • {fact}
  • + ))} +
+
+ +
+
+
+ {screenshot.alt} +
+
{screenshot.alt}
+
+
+
+
diff --git a/apps/website/src/components/download/DownloadSection.astro b/apps/website/src/components/download/DownloadSection.astro new file mode 100644 index 000000000..4dc770988 --- /dev/null +++ b/apps/website/src/components/download/DownloadSection.astro @@ -0,0 +1,35 @@ +--- +interface Props { + id?: string; + eyebrow: string; + title: string; + accent?: string; + lead?: string; +} + +const { id, eyebrow, title, accent, lead } = Astro.props; +--- + +
+
+ +
+
+ {eyebrow} +

+ {title} + {accent && ( + + {' '} + {accent} + + )} +

+ {lead &&

{lead}

} +
+ +
+ +
+
+
diff --git a/apps/website/src/components/download/FeatureList.astro b/apps/website/src/components/download/FeatureList.astro new file mode 100644 index 000000000..cbc23d1bb --- /dev/null +++ b/apps/website/src/components/download/FeatureList.astro @@ -0,0 +1,27 @@ +--- +interface FeatureItem { + title: string; + description: string; +} + +interface Props { + items: FeatureItem[]; +} + +const { items } = Astro.props; +--- + +
+ {items.map((item, index) => ( +
+
+ + {String(index + 1).padStart(2, '0')} + + +
+

{item.title}

+

{item.description}

+
+ ))} +
diff --git a/apps/website/src/components/download/OfficialSourcesNote.astro b/apps/website/src/components/download/OfficialSourcesNote.astro new file mode 100644 index 000000000..53a96c530 --- /dev/null +++ b/apps/website/src/components/download/OfficialSourcesNote.astro @@ -0,0 +1,23 @@ +--- +import { LATEST_RELEASE_URL } from '../../lib/downloads'; +--- + +
+
+ +
+

Official downloads only

+

+ IPTVnator is a player, not a TV service. It is distributed exclusively through{' '} + GitHub releases{' '} + and the package managers listed on this page. Websites that sell "IPTVnator subscriptions", channel packages or + "activated" copies are not affiliated with the project.{' '} + How to recognize unofficial sites. +

+
+
+
diff --git a/apps/website/src/components/download/PackageList.astro b/apps/website/src/components/download/PackageList.astro new file mode 100644 index 000000000..b4a5b18bd --- /dev/null +++ b/apps/website/src/components/download/PackageList.astro @@ -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; +--- + +
+
+ {title} +
+
    + {rows.map(({ download, command, note }) => { + const size = formatAssetSize(download.size); + return ( +
  • +
    +

    {download.label}

    +

    + {download.detail} + {size && · {size}} +

    + {note &&

    {note}

    } +
    +
    + {command && ( + {command} + )} +
    + + + Download + +
  • + ); + })} +
+
diff --git a/apps/website/src/components/download/PlatformSwitcher.astro b/apps/website/src/components/download/PlatformSwitcher.astro new file mode 100644 index 000000000..482bd3248 --- /dev/null +++ b/apps/website/src/components/download/PlatformSwitcher.astro @@ -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); +--- + +
+
+ +
+
+
+ Other platforms +

+ Also available for{' '} + {others.map((platform) => PLATFORM_LABELS[platform]).join(' and ')} +

+
+ + +
+
+
diff --git a/apps/website/src/components/download/RelatedPosts.astro b/apps/website/src/components/download/RelatedPosts.astro new file mode 100644 index 000000000..f294e327a --- /dev/null +++ b/apps/website/src/components/download/RelatedPosts.astro @@ -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 => post !== undefined); +--- + +{selected.length > 0 && ( +
+ {selected.map((post) => ( + + ))} +
+)} diff --git a/apps/website/src/components/download/SpecList.astro b/apps/website/src/components/download/SpecList.astro new file mode 100644 index 000000000..aa3892935 --- /dev/null +++ b/apps/website/src/components/download/SpecList.astro @@ -0,0 +1,21 @@ +--- +interface SpecItem { + term: string; + value: string; +} + +interface Props { + items: SpecItem[]; +} + +const { items } = Astro.props; +--- + +
+ {items.map((item) => ( +
+
{item.term}
+
{item.value}
+
+ ))} +
diff --git a/apps/website/src/lib/download-schema.ts b/apps/website/src/lib/download-schema.ts new file mode 100644 index 000000000..57b14399a --- /dev/null +++ b/apps/website/src/lib/download-schema.ts @@ -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> { + 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 }, + ], + }, + ]; +} diff --git a/apps/website/src/lib/downloads.ts b/apps/website/src/lib/downloads.ts new file mode 100644 index 000000000..d3607da59 --- /dev/null +++ b/apps/website/src/lib/downloads.ts @@ -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 { + fileName: string; + url: string; + size: number | null; +} + +export const DOWNLOAD_OPTIONS: Record = { + 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 | undefined; + +/** Resolves once per build; every page shares the same answer. */ +export function getLatestRelease(): Promise { + 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 { + const published = await fetchPublishedRelease(); + return published ?? fallbackRelease(); +} + +async function fetchPublishedRelease(): Promise { + if (process.env.WEBSITE_SKIP_RELEASE_FETCH === '1') { + return null; + } + try { + const headers: Record = { + 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; + 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; + 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}.`); +} diff --git a/apps/website/src/lib/platforms.ts b/apps/website/src/lib/platforms.ts new file mode 100644 index 000000000..390ccc280 --- /dev/null +++ b/apps/website/src/lib/platforms.ts @@ -0,0 +1,26 @@ +export type DownloadPlatform = 'windows' | 'macos' | 'linux'; + +export const DOWNLOAD_PLATFORMS: readonly DownloadPlatform[] = ['windows', 'macos', 'linux']; + +export const PLATFORM_LABELS: Record = { + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', +}; + +/** Site-relative download page per platform (includes the GitHub Pages base). */ +export const PLATFORM_PAGES: Record = { + 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 = { + 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', +}; diff --git a/apps/website/src/lib/site.ts b/apps/website/src/lib/site.ts new file mode 100644 index 000000000..0d5c76926 --- /dev/null +++ b/apps/website/src/lib/site.ts @@ -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}`; +} diff --git a/apps/website/src/pages/download/index.astro b/apps/website/src/pages/download/index.astro new file mode 100644 index 000000000..fd60b2d98 --- /dev/null +++ b/apps/website/src/pages/download/index.astro @@ -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 }, + ], + }, +]; +--- + + +
+
+ +
+
+ Download +

+ Get IPTVnator +

+

+ 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. +

+

+ Version {release.version} + {releaseDate && · Released {releaseDate}} + · + Release notes +

+
+ + +
+
+ +
+
+
+
+
+ Package managers +

+ One command instead +

+

+ 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. +

+

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

+
+
+ + + +
+
+ +
+ +
+
+
+
diff --git a/apps/website/src/pages/download/linux.astro b/apps/website/src/pages/download/linux.astro new file mode 100644 index 000000000..c3c51d3ad --- /dev/null +++ b/apps/website/src/pages/download/linux.astro @@ -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 = { + '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 = { + '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, +}); +--- + + + + + +
+ + +
+
+ + +
+ +
+ + + sudo eselect repository enable gentoo-zh +sudo emerge --sync gentoo-zh +sudo emerge iptvnator-bin +
+
+
+ + +
+ +

+ Launchers start IPTVnator with --ozone-platform=x11, so Wayland sessions need Xwayland, which GNOME and KDE + ship by default. If the app exits with Ozone or Wayland errors, start it with{' '} + iptvnator --ozone-platform=x11. +

+
+ +

+ The experimental engine drives your distribution's mpv binary inside the app window on x86_64 under X11 or + Xwayland. Install mpv first (for example sudo apt install mpv) and opt in under Settings › Playback. The + Flatpak and Snap sandboxes do not expose the host mpv, so the engine stays unavailable there. +

+
+ +

+ If the app aborts with "The SUID sandbox helper binary was found, but is not configured correctly", run{' '} + sudo chown root:root chrome-sandbox and sudo chmod 4755 chrome-sandbox in the + installation directory once, or start the app with --no-sandbox. +

+
+ +

+ The x86_64 .deb depends on libmpv2, which Ubuntu 22.04 does not ship. Use the AppImage there; + it bundles everything it needs. +

+
+
+
+ + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + +
diff --git a/apps/website/src/pages/download/macos.astro b/apps/website/src/pages/download/macos.astro new file mode 100644 index 000000000..922a1a1d2 --- /dev/null +++ b/apps/website/src/pages/download/macos.astro @@ -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, +}); +--- + + + +

+ Not sure which build? Open the Apple menu › About This Mac. "Chip: Apple M…" means Apple Silicon, "Processor: Intel…" means Intel. +

+
+ + +
+ +
+ + +

+ 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{' '} + this post. +

+
+
+
+
+ + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + +
diff --git a/apps/website/src/pages/download/windows.astro b/apps/website/src/pages/download/windows.astro new file mode 100644 index 000000000..c3a2353ee --- /dev/null +++ b/apps/website/src/pages/download/windows.astro @@ -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--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, +}); +--- + + + + + +
+ + +

+ 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{' '} + official GitHub releases page, + never for copies that arrive through other download sites. +

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

+ 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{' '} + MPV{' '} + or{' '} + VLC, + pick it under Settings › Playback, and IPTVnator launches it for every stream while keeping your library, favorites + and remote control in the app. +

+

+ 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. +

+
+
+ Playback settings with MPV selected as the external player +
+
+
+ + + + + + +
+ +
+
+ + + +
+ +
+
+ + +
diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro index 1d1af40f6..0de076289 100644 --- a/apps/website/src/pages/index.astro +++ b/apps/website/src/pages/index.astro @@ -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: { diff --git a/tools/testing/website-download-pages.test.mjs b/tools/testing/website-download-pages.test.mjs new file mode 100644 index 000000000..36f60dd7d --- /dev/null +++ b/tools/testing/website-download-pages.test.mjs @@ -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(/