Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,13 @@ media/
tmp/
deploy/
node_modules/

# Local and test-only artifacts
.DS_Store
.idea/
.vscode/
*.log
coverage/
.cache/
test-results/
playwright-report/
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ minimum line, branch, and function coverage. Browser behavior is enforced by the
separate Chromium suite and is not included in the server coverage percentage.

GitHub Actions runs type checking, linting, formatting checks, server coverage,
and browser tests for pull requests and pushes to `master`. The `quality` job is
intended to be configured as a required status check for the protected branch.
and browser tests for pull requests targeting `master` and pushes to `master`.
`master` is the current primary CI/CD branch, and the `quality` job is intended
to be configured as a required status check for it.

---

Expand Down Expand Up @@ -124,7 +125,13 @@ After one admin exists, manage roles from `/admin`. Continue with `SETUP.md` for

### Subsequent deploys

Run from a clean local checkout at the current `origin/master`. The script verifies the required GitHub Actions `quality` check, builds the hashed browser assets, rsyncs server code and assets, restarts the services, performs an HTTP smoke check, and records the deployed commit in `/opt/vidium/.deployed-revision`.
Run from a clean local checkout at the current `origin/master`. The deployment
scripts intentionally deploy only the current `master` revision: they verify
that `HEAD` matches `origin/master` and that the required GitHub Actions
`quality` check succeeded for that commit. They then build the hashed browser
assets, rsync server code and assets, restart the services, perform an HTTP
smoke check, and record the deployed commit in
`/opt/vidium/.deployed-revision`.

```bash
scripts/deploy.sh root@<VPS_IP>
Expand Down
14 changes: 14 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ vidium is deployed with rsync from a clean local checkout. The local machine bui

The supported bootstrap platform is Ubuntu 24.04 x86_64. Both `setup.sh` and `dev-env-setup.sh` reject other architectures before downloads or machine changes.

## CI/CD Branch Policy

`master` is the current primary CI/CD branch. GitHub Actions runs the `quality`
workflow for pull requests targeting `master` and for pushes to `master`.

Production deployment is also tied to `master`: `scripts/deploy.sh` and
`scripts/deploy-static.sh` require a clean checkout whose `HEAD` matches the
current `origin/master`, then require a successful GitHub Actions `quality`
check for that exact commit before changing the VPS. A different local branch
or revision must not be deployed through the supported scripts.

If the primary branch changes in the future, update the GitHub Actions workflow,
deployment scripts, and this documentation together.

## Runtime And Maintenance Files

The production app directory is `/opt/vidium`. Application services run as the dedicated unprivileged `vidium` account; code and the pinned runtime remain root-owned.
Expand Down
2 changes: 1 addition & 1 deletion scripts/register-local-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { existsSync } from 'node:fs';
import { config } from '../src/config.ts';
import { db } from '../src/lib/db.ts';
import { generateVideoUid } from '../src/lib/video.ts';
import { generateVideoUid } from '../src/lib/video-mutations.ts';

const MEDIA_ID_RE = /^[a-zA-Z0-9_-]+$/;
const MANUAL_CHANNEL_ID = 1;
Expand Down
129 changes: 129 additions & 0 deletions scripts/tests/frontend-browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,52 @@ browserTest(
},
);

browserTest(
'media queue polling reports one warning per failure streak and retries',
async (page) => {
await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') });
const warnings = [];
page.on('console', (message) => {
if (message.type() === 'warning') warnings.push(message.text());
});
await page.evaluate((uid) => {
localStorage.setItem(
'vidium:media-queue:v1',
JSON.stringify([{ uid, type: 'video', title: 'Queued', status: 'queued', addedAt: 1 }]),
);
}, UID_ONE);
let calls = 0;
await page.route('**/api/status?*', async (route) => {
calls += 1;
await page.evaluate((value) => {
window.__statusPollCalls = value;
}, calls);
await route.fulfill({ status: 503, json: { error: 'temporary' } });
});
await mountRealPart(page, {
partName: 'media-queue',
id: 'media-queue',
state: mediaQueueState(),
});
await page.locator('[data-action="open"]').click();
await page.clock.runFor(0);
await page.waitForFunction(
() => window.__VIDIUM_TEST__.instances['media-queue'].private.pollErrorReported,
);
await page.waitForFunction(
() => window.__VIDIUM_TEST__.instances['media-queue'].private.pollTimer !== null,
);
await page.clock.runFor(5000);
await page.waitForFunction(() => window.__statusPollCalls === 2);

assert.equal(calls, 2);
assert.equal(
warnings.filter((value) => value.includes('Media queue polling failed')).length,
1,
);
},
);

async function mockPlayerMedia(page, id, values = {}) {
await page.evaluate(
({ instanceId, initial }) => {
Expand Down Expand Up @@ -1813,6 +1859,89 @@ browserTest('player records the first play event only once', async (page) => {
assert.equal(playRequests, 1);
});

browserTest(
'player handles browser play rejection and reports failed play recording',
async (page) => {
await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') });
const warnings = [];
const pageErrors = [];
page.on('console', (message) => {
if (message.type() === 'warning') warnings.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.route('**/api/play', (route) =>
route.fulfill({ status: 503, json: { error: 'temporary' } }),
);
await mountRealPart(page, {
partName: 'player-page',
id: 'player-page',
state: playerState(),
});
await mockPlayerMedia(page, 'player-page', { paused: true });
await page.evaluate(() => {
const media = window.__VIDIUM_TEST__.instances['player-page'].refs.media;
media.play = () =>
Promise.reject(Object.assign(new Error('autoplay blocked'), { name: 'NotAllowedError' }));
});

await page.locator('[data-action="toggle-play"]').click();
await page.evaluate(() => {
window.__VIDIUM_TEST__.instances['player-page'].refs.media.dispatchEvent(new Event('play'));
});
await page.waitForFunction(
() => window.__VIDIUM_TEST__.instances['player-page'].private.playRecordErrorReported,
);

assert.deepEqual(pageErrors, []);
assert.equal(
warnings.filter((value) => value.includes('play event recording failed')).length,
1,
);
assert.equal(warnings.filter((value) => value.includes('media play failed')).length, 0);
},
);

browserTest('player reports resume storage failure without breaking controls', async (page) => {
await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') });
const warnings = [];
const pageErrors = [];
page.on('console', (message) => {
if (message.type() === 'warning') warnings.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await mountRealPart(page, {
partName: 'player-page',
id: 'player-page',
state: playerState(),
});
await mockPlayerMedia(page, 'player-page', { currentTime: 100, paused: true });
await page.evaluate(() => {
Object.defineProperty(Storage.prototype, 'setItem', {
configurable: true,
value() {
throw new DOMException('denied');
},
});
});

await page.locator('[data-action="seek"][data-seek="15"]').click();
await page.waitForFunction(
() => window.__VIDIUM_TEST__.instances['player-page'].private.resumeStorageErrorReported,
);

assert.deepEqual(pageErrors, []);
assert.equal(
warnings.filter((value) => value.includes('resume storage is unavailable')).length,
1,
);
assert.equal(
await page.evaluate(
() => window.__VIDIUM_TEST__.instances['player-page'].refs.media.currentTime,
),
115,
);
});

browserTest(
'player share and clipboard handle success, rejection, and late completion after destroy',
async (page) => {
Expand Down
Loading