mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* docs(website): self-hosting guide for server storage and a current Docker page The Docker download page still said that every browser keeps its own sources and that the container holds no state, which stopped being true when server storage landed (#1903). It now describes the shared library on the volume, lists IPTVNATOR_STORAGE, IPTVNATOR_DATA_DIR and IPTVNATOR_ACCESS_TOKEN, shows the volume in the compose snippet and points at a new step-by-step guide. The guide covers a NAS deployment with Docker Compose: the compose file, the first start, a second device and the browser-data move, the access token, HTTPS behind a reverse proxy, where the database lives and how to back it up, updating, NAS notes and troubleshooting. Its two screenshots were captured from the published image on a fresh volume with the project's mock providers. The comparison page's data FAQ and the guides hub list the guide, and the docker README links to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): certificate prerequisites and a safe host backup in the guide Review round 1 (Codex, Greptile): the Caddy example used a name no public authority can certify, so the HTTPS step now explains the two workable setups, a name you own with a Let's Encrypt certificate or a LAN name with Caddy's internal authority trusted on each device, and shows both. The host-side backup no longer suggests copying the database and its journal from a running container; the docker README says the same. The token step loses literal backticks that the step rail rendered as text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): a restored backup keeps its sign-ins Review round 2 (Greptile): the instance id lives in the database, so a restored copy of the same database keeps every browser signed in. The guide now says so in the restore paragraph and in troubleshooting, and reserves the new-server flow for a database with another identity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): two libraries need two container names Review round 3 (Codex): the FAQ on separate libraries sent readers to copy the compose file, whose fixed container_name would make the second container fail to start. The answer now lists the container name among the values that must differ. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): say when another device's changes arrive Review round 4 (Greptile): an open page re-reads the synced user data when its tab comes back to the front or on a reload, not when a list inside it is opened again. The guide's sentence on propagation says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): keep the self-hosting guide a draft until the 0.25 release The post stays out of the published site for now. The Docker page links it only when the post is built (published, or drafts included), the website test checks it in the drafts-included run like the other draft guides, and the docker README link waits for the publication. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
import { access, readFile } from 'node:fs/promises';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { JSDOM } from 'jsdom';
|
|
import { parse as parseYaml } from 'yaml';
|
|
import { launchBrowser, serveDist } from './website-browser-support.mjs';
|
|
|
|
/**
|
|
* Structural checks for guide posts: they must carry FAQPage structured data
|
|
* next to the BlogPosting entry, link to the download hub, and reference only
|
|
* screenshots that the build actually shipped.
|
|
*/
|
|
|
|
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
|
|
const SITE = 'https://4gray.github.io/iptvnator';
|
|
|
|
const GUIDES = [
|
|
{
|
|
slug: 'player-controls-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-player-subtitles-dark.png'],
|
|
},
|
|
{
|
|
slug: 'library-organization-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-library-watched-dark.png'],
|
|
},
|
|
{
|
|
slug: 'stable-nightly-updates-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-update-channel-dark.png'],
|
|
},
|
|
{
|
|
slug: 'playlist-backup-restore-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-backup-dark.png'],
|
|
},
|
|
{
|
|
slug: 'fullscreen-channel-episode-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png'],
|
|
},
|
|
{
|
|
slug: 'stream-info-diagnostics-guide',
|
|
screenshots: ['blog/feature-guides/screenshots/guide-stream-info-dark.png'],
|
|
},
|
|
{
|
|
slug: 'm3u-programme-guide',
|
|
screenshots: ['blog/feature-guides/multi-epg-v0-24.jpg'],
|
|
},
|
|
{
|
|
slug: 'xtream-codes-setup-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-xtream-add-playlist-dark.png',
|
|
'blog/guides/screenshots/guide-xtream-auto-detect-dark.png',
|
|
'blog/guides/screenshots/guide-xtream-live-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'stalker-portal-setup-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-stalker-add-playlist-dark.png',
|
|
'blog/guides/screenshots/guide-stalker-live-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'm3u-playlist-epg-setup-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-m3u-add-playlist-dark.png',
|
|
'blog/guides/screenshots/guide-m3u-live-groups-dark.png',
|
|
'blog/guides/screenshots/guide-epg-settings-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'offline-downloads-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-downloads-movie-detail-dark.png',
|
|
'blog/guides/screenshots/guide-downloads-manager-dark.png',
|
|
'blog/guides/screenshots/guide-downloads-offline-movie-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'alternative-sources-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-sources-chip-dark.png',
|
|
'blog/guides/screenshots/guide-sources-menu-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'remote-control-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-remote-settings-dark.png',
|
|
'blog/guides/screenshots/guide-remote-phone-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'epg-wrong-program-fix',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-epg-map-menu-dark.png',
|
|
'blog/guides/screenshots/guide-epg-map-dialog-dark.png',
|
|
'blog/guides/screenshots/guide-epg-offset-dark.png',
|
|
],
|
|
},
|
|
{
|
|
slug: 'tmdb-metadata-guide',
|
|
screenshots: ['blog/guides/screenshots/guide-tmdb-settings-dark.png'],
|
|
},
|
|
];
|
|
|
|
/** Guides still marked `draft: true`: built, and therefore checked, only with PUBLIC_INCLUDE_DRAFTS. */
|
|
const DRAFT_ONLY_GUIDES = [
|
|
{
|
|
slug: 'self-hosted-server-storage-guide',
|
|
screenshots: [
|
|
'blog/guides/screenshots/guide-server-settings-dark.png',
|
|
'blog/guides/screenshots/guide-server-migration-dark.png',
|
|
],
|
|
},
|
|
];
|
|
|
|
if (process.env.PUBLIC_INCLUDE_DRAFTS === 'true') {
|
|
GUIDES.push(...DRAFT_ONLY_GUIDES);
|
|
}
|
|
|
|
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
|
|
|
const FEATURE_GUIDE_SHOTS = {
|
|
'stable-nightly-updates-guide': 'guide-update-channel',
|
|
'playlist-backup-restore-guide': 'guide-backup',
|
|
'library-organization-guide': 'guide-library-watched',
|
|
'player-controls-guide': 'guide-player-subtitles',
|
|
'stream-info-diagnostics-guide': 'guide-stream-info',
|
|
'fullscreen-channel-episode-guide': 'guide-fullscreen-channels',
|
|
};
|
|
|
|
if (process.env.PUBLIC_INCLUDE_DRAFTS === 'true') {
|
|
for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) {
|
|
if (GUIDES.some((guide) => guide.slug === slug)) continue;
|
|
GUIDES.push({ slug, screenshots: [`blog/feature-guides/screenshots/${shot}-dark.png`] });
|
|
}
|
|
}
|
|
|
|
test('feature guide drafts reference screenshots that ship with the site', async () => {
|
|
for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) {
|
|
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
|
|
const screenshot = `blog/feature-guides/screenshots/${shot}-dark.png`;
|
|
assert.ok(source.includes(`](/iptvnator/${screenshot})`), `${slug}: inline screenshot`);
|
|
assert.ok(source.includes(`heroImage: /iptvnator/${screenshot}`), `${slug}: card image`);
|
|
await access(new URL(screenshot, distRoot));
|
|
}
|
|
});
|
|
|
|
const HUB_GROUPS = ['getting-started', 'live-tv-epg', 'playback', 'library', 'updates'];
|
|
const DRAFT_GUIDES = [
|
|
'm3u-programme-guide', 'stable-nightly-updates-guide', 'fullscreen-channel-episode-guide',
|
|
'player-controls-guide', 'stream-info-diagnostics-guide', 'playlist-backup-restore-guide',
|
|
'library-organization-guide', 'self-hosted-server-storage-guide',
|
|
];
|
|
|
|
test('guides hub groups working article links by task and keeps draft visibility consistent', async () => {
|
|
const html = await readDist('guides/index.html');
|
|
const doc = new JSDOM(html).window.document;
|
|
assert.equal(doc.querySelector('link[rel="canonical"]').href, `${SITE}/guides/`);
|
|
assert.equal(doc.querySelectorAll('h1').length, 1);
|
|
for (const id of HUB_GROUPS) {
|
|
const section = doc.getElementById(id);
|
|
assert.ok(section?.querySelector('h2'), `${id}: named section`);
|
|
assert.ok(section.querySelector('[data-guide-article]'), `${id}: not an empty category`);
|
|
assert.ok(doc.querySelector(`nav[aria-label="Guide topics"] a[href="#${id}"]`));
|
|
}
|
|
const links = [...doc.querySelectorAll('[data-guide-article]')];
|
|
assert.equal(new Set(links.map((link) => link.dataset.guideArticle)).size, links.length);
|
|
for (const link of links) {
|
|
const slug = link.dataset.guideArticle;
|
|
assert.equal(link.getAttribute('href'), `/iptvnator/blog/${slug}/`);
|
|
await access(new URL(`blog/${slug}/index.html`, distRoot));
|
|
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
|
|
const meta = parseYaml(source.split('---')[1]);
|
|
assert.equal(link.querySelector('h3').textContent, meta.title);
|
|
assert.equal(link.textContent.includes('Draft preview'), meta.draft === true);
|
|
}
|
|
for (const slug of DRAFT_GUIDES) {
|
|
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
|
|
const meta = parseYaml(source.split('---')[1]);
|
|
const visible = !meta.draft || process.env.PUBLIC_INCLUDE_DRAFTS === 'true';
|
|
assert.equal(links.some((link) => link.dataset.guideArticle === slug), visible, `${slug}: draft visibility`);
|
|
}
|
|
const schema = extractJsonLd(html);
|
|
const collection = schema.find((entry) => entry['@type'] === 'CollectionPage');
|
|
assert.equal(collection.url, `${SITE}/guides/`);
|
|
assert.deepEqual(collection.hasPart.map((entry) => entry.url), links.map((link) => `https://4gray.github.io${link.getAttribute('href')}`));
|
|
assert.equal(schema.find((entry) => entry['@type'] === 'BreadcrumbList').itemListElement.at(-1).item, `${SITE}/guides/`);
|
|
assert.match(await readDist('sitemap-0.xml'), /<loc>https:\/\/4gray.github.io\/iptvnator\/guides\/<\/loc>/);
|
|
});
|
|
|
|
test('guides are discoverable from the header, footer and blog', async () => {
|
|
const home = new JSDOM(await readDist('index.html')).window.document;
|
|
assert.ok(home.querySelector('header a[href="/iptvnator/guides/"]'));
|
|
assert.ok(home.querySelector('#mobile-menu a[href="/iptvnator/guides/"]'));
|
|
assert.ok(home.querySelector('footer a[href="/iptvnator/guides/"]'));
|
|
const blog = new JSDOM(await readDist('blog/index.html')).window.document;
|
|
assert.ok(blog.querySelector('main a[href="/iptvnator/guides/"]'));
|
|
});
|
|
|
|
test('guides hub: responsive navigation and task anchors work in Chromium', async (t) => {
|
|
const browser = await launchBrowser();
|
|
if (!browser) return t.skip('Chromium is not installed locally');
|
|
const { server, origin } = await serveDist();
|
|
try {
|
|
for (const width of [390, 768, 1024, 1280]) {
|
|
const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
|
|
try {
|
|
await page.goto(`${origin}/iptvnator/guides/`);
|
|
await page.evaluate(() => document.fonts.ready);
|
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `${width}: no horizontal overflow`);
|
|
if (width < 1024) {
|
|
const button = page.getByRole('button', { name: 'Toggle menu' });
|
|
await button.click();
|
|
assert.equal(await button.getAttribute('aria-expanded'), 'true');
|
|
await page.locator('#mobile-menu').getByRole('link', { name: 'Guides', exact: true }).click();
|
|
await page.waitForURL(`${origin}/iptvnator/guides/`);
|
|
} else {
|
|
const bounds = await page.locator('#site-header a:visible').evaluateAll((links) => links.map((link) => {
|
|
const { left, right } = link.getBoundingClientRect();
|
|
return { left, right };
|
|
}));
|
|
for (let index = 1; index < bounds.length; index++) {
|
|
assert.ok(bounds[index].left >= bounds[index - 1].right, `${width}: header links do not overlap`);
|
|
}
|
|
}
|
|
await page.getByRole('navigation', { name: 'Guide topics' }).getByRole('link', { name: /Updates/ }).click();
|
|
await page.waitForFunction(() => {
|
|
const top = document.getElementById('updates').getBoundingClientRect().top;
|
|
return top >= 80 && top < 400;
|
|
});
|
|
assert.equal(new URL(page.url()).hash, '#updates');
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
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 guide of GUIDES) {
|
|
test(`${guide.slug}: BlogPosting and FAQPage structured data`, async () => {
|
|
const html = await readDist(`blog/${guide.slug}/index.html`);
|
|
const schema = extractJsonLd(html);
|
|
|
|
assert.ok(schema.some((entry) => entry['@type'] === 'BlogPosting'), 'Expected a BlogPosting entry.');
|
|
|
|
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.');
|
|
assert.match(html, /Frequently asked questions/);
|
|
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/blog/${guide.slug}/"`));
|
|
});
|
|
|
|
test(`${guide.slug}: links to the download hub and ships its screenshots`, async () => {
|
|
const html = await readDist(`blog/${guide.slug}/index.html`);
|
|
assert.match(html, /href="\/iptvnator\/download\/"/);
|
|
|
|
for (const screenshot of guide.screenshots) {
|
|
assert.match(html, new RegExp(`src="/iptvnator/${screenshot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
|
await access(new URL(screenshot, distRoot));
|
|
}
|
|
});
|
|
}
|