Compare commits
59 Commits
codex/jame
...
still-imag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6c79e360 | ||
|
|
fcbc9344ba | ||
|
|
a2ab53ea8a | ||
|
|
1f79750ec5 | ||
|
|
92432e4d83 | ||
|
|
a48ea300cd | ||
|
|
c60f1c327e | ||
|
|
17115dc8e7 | ||
|
|
46fc756b68 | ||
|
|
cda8b068c7 | ||
|
|
b588dca461 | ||
|
|
088937d4de | ||
|
|
4e940e7321 | ||
|
|
450dc80488 | ||
|
|
1383e89a25 | ||
|
|
1352ed9f29 | ||
|
|
3f913de0c3 | ||
|
|
23097918e9 | ||
|
|
ca042ccbe3 | ||
|
|
32d1f6891b | ||
|
|
63cea89b68 | ||
|
|
69704d3e1a | ||
|
|
ca47528581 | ||
|
|
dbcee5d5f8 | ||
|
|
e8982162c0 | ||
|
|
92ce7589f9 | ||
|
|
7ceefa3d6a | ||
|
|
b6e019c352 | ||
|
|
41004bf14d | ||
|
|
59871a5b88 | ||
|
|
313d5aef99 | ||
|
|
d45fa49a46 | ||
|
|
6f62c8c139 | ||
|
|
007e5b5352 | ||
|
|
5c94a7d46a | ||
|
|
c242641b2d | ||
|
|
4fa33e9810 | ||
|
|
c0c472f3ef | ||
|
|
27c694901d | ||
|
|
5177a4d441 | ||
|
|
e9a597c415 | ||
|
|
4884589f36 | ||
|
|
0f402a8d20 | ||
|
|
703aef617b | ||
|
|
555027a61c | ||
|
|
11b10e38e2 | ||
|
|
e3821621f3 | ||
|
|
efac16ddaa | ||
|
|
34b1173ea6 | ||
|
|
42c68609ec | ||
|
|
180a28d3e4 | ||
|
|
301fab335f | ||
|
|
6bcaccbcaf | ||
|
|
58d0e6f140 | ||
|
|
ca10fe6486 | ||
|
|
593ecaecd9 | ||
|
|
18e4459bab | ||
|
|
d264d0748a | ||
|
|
f465cc86d0 |
1
.gitignore
vendored
@@ -54,3 +54,4 @@ plans/transport-shots/
|
|||||||
# Search button visual review screenshots
|
# Search button visual review screenshots
|
||||||
plans/search-button-shots/
|
plans/search-button-shots/
|
||||||
plans/search-button-final-shots/
|
plans/search-button-final-shots/
|
||||||
|
plans/admin-ops-shots/
|
||||||
|
|||||||
11
.impeccable/surfaces/frontend-watch-party-client-js.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Watch party companion
|
||||||
|
|
||||||
|
Mode: Operate. Primary target: frontend/watch-party-client.js. Related targets: frontend/styles.css, frontend/party-dj.js, frontend/index.html.
|
||||||
|
|
||||||
|
User direction: compact cinema companion; the person who starts the party must retain playback controls. Inherit the application's theme variables, typography, light/high-contrast preferences, and safe-area clearance. This is a component redesign inside the existing product, not a replacement for Classic's visual system.
|
||||||
|
|
||||||
|
Keep invitation code, current title, and host transport above Chat / Queue / Room tabs. Chat is the default; Room holds participant names, local playback preferences, voice, presentation and guest permissions. Give Queue an explicit empty state and keep searches stable when votes change. No new font, image, framework, backdrop filter, or animation dependency.
|
||||||
|
|
||||||
|
Keyboard: labeled inputs, roving tab focus and arrow navigation, visible focus, live connection status, chat additions announced without rebuilding the log. Controls have 44px minimum height. Small screens retain navigation clearance and scroll Room/Queue independently.
|
||||||
|
|
||||||
|
Evidence: tests/watch-party-browser.cjs uses the actual frontend chunk/CSS and real Bun party relay with deterministic playback objects. Screenshots in .impeccable/review are fixture captures; they do not prove live video playback, voice quality or production network throughput.
|
||||||
39
alt/bench.mjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const base = process.argv[2];
|
||||||
|
if (!base) { console.error('usage: node alt/bench.mjs <baseUrl> [--duration-ms=10000]'); process.exit(2); }
|
||||||
|
const duration = Number(process.argv.find(x => x.startsWith('--duration-ms='))?.split('=')[1]) || 10000;
|
||||||
|
const cachedId = process.env.TEST_MEDIA_ID || 'dQw4w9WgXcQ';
|
||||||
|
const query = encodeURIComponent(process.env.BENCH_QUERY || 'ytplayer-bench-seed');
|
||||||
|
const manifest = await (await fetch(new URL('/api/manifest', base))).json();
|
||||||
|
const cssHash = manifest.files?.['/styles.css']?.h;
|
||||||
|
const targets = [
|
||||||
|
['version', '/api/version'],
|
||||||
|
['static', `/styles.css${cssHash ? `?v=${cssHash}` : ''}`],
|
||||||
|
['cached-search', `/api/search?q=${query}`],
|
||||||
|
['media-range', `/api/media/${cachedId}?g=1`, { Range: 'bytes=0-1048575' }],
|
||||||
|
];
|
||||||
|
async function run(label, path, concurrency, headers = {}) {
|
||||||
|
let count = 0, responseBytes = 0, errors = 0;
|
||||||
|
const latencies = [];
|
||||||
|
const until = Date.now() + duration;
|
||||||
|
async function worker() {
|
||||||
|
while (Date.now() < until) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch(new URL(path, base), { headers });
|
||||||
|
const body = await response.arrayBuffer();
|
||||||
|
responseBytes += body.byteLength;
|
||||||
|
if (!response.ok) errors++;
|
||||||
|
} catch { errors++; }
|
||||||
|
latencies.push(performance.now() - start);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||||
|
latencies.sort((a, b) => a - b);
|
||||||
|
const pick = p => latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))] || 0;
|
||||||
|
return { route: label, concurrency, durationMs: duration, requests: count, rps: +(count / (duration / 1000)).toFixed(1), responseBytes, errors, p50Ms: +pick(.5).toFixed(1), p95Ms: +pick(.95).toFixed(1), p99Ms: +pick(.99).toFixed(1) };
|
||||||
|
}
|
||||||
|
for (const concurrency of [1, 10, 50, 200]) {
|
||||||
|
for (const [label, path, headers] of targets) console.log(JSON.stringify(await run(label, path, concurrency, headers)));
|
||||||
|
}
|
||||||
71
alt/compare.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const [baseUrl, candidateUrl = process.env.RUST_URL || 'http://127.0.0.1:3000'] = process.argv.slice(2);
|
||||||
|
if (!baseUrl) {
|
||||||
|
console.error('usage: node alt/compare.mjs <bun-base-url> [rust-url]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const mediaId = process.env.TEST_MEDIA_ID || 'dQw4w9WgXcQ';
|
||||||
|
const testQuery = encodeURIComponent(process.env.TEST_QUERY || 'ytplayer-bench-seed');
|
||||||
|
const results = [];
|
||||||
|
const shape = value => Array.isArray(value) ? value.map(shape) : value && typeof value === 'object'
|
||||||
|
? Object.fromEntries(Object.keys(value).sort().map(key => [key, shape(value[key])])) : typeof value;
|
||||||
|
const headerSet = response => ({
|
||||||
|
contentType: (response.headers.get('content-type') || '').split(';')[0],
|
||||||
|
cache: response.headers.get('cache-control'),
|
||||||
|
range: response.headers.get('content-range'),
|
||||||
|
acceptRanges: response.headers.get('accept-ranges'),
|
||||||
|
encoding: response.headers.get('content-encoding'),
|
||||||
|
assetHash: response.headers.get('x-asset-hash'),
|
||||||
|
etag: response.headers.get('etag'),
|
||||||
|
});
|
||||||
|
async function inspect(url, path, { method = 'GET', headers = {} } = {}) {
|
||||||
|
const response = await fetch(new URL(path, url), { method, headers });
|
||||||
|
const type = response.headers.get('content-type') || '';
|
||||||
|
let body = null;
|
||||||
|
if (type.includes('json')) body = await response.json();
|
||||||
|
else if (type.startsWith('text/')) body = await response.text();
|
||||||
|
else await response.arrayBuffer();
|
||||||
|
return { status: response.status, headers: headerSet(response), shape: body ? shape(body) : null, body };
|
||||||
|
}
|
||||||
|
function check(name, a, b, fields) {
|
||||||
|
const headersMatch = fields.every(field => a.headers[field] === b.headers[field]);
|
||||||
|
const compatible = a.status === b.status && JSON.stringify(a.shape) === JSON.stringify(b.shape) && headersMatch;
|
||||||
|
results.push({ name, compatible, base: { status: a.status, headers: a.headers, shape: a.shape }, candidate: { status: b.status, headers: b.headers, shape: b.shape } });
|
||||||
|
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}: ${a.status} / ${b.status}${headersMatch ? '' : ` headers ${JSON.stringify(a.headers)} / ${JSON.stringify(b.headers)}`}`);
|
||||||
|
}
|
||||||
|
const [baseManifest, candidateManifest] = await Promise.all([inspect(baseUrl, '/api/manifest'), inspect(candidateUrl, '/api/manifest')]);
|
||||||
|
check('manifest', baseManifest, candidateManifest, ['cache', 'etag', 'contentType']);
|
||||||
|
for (const [name, url, manifest] of [['base', baseUrl, baseManifest.body], ['candidate', candidateUrl, candidateManifest.body]]) {
|
||||||
|
const [index, sw] = await Promise.all([inspect(url, '/'), inspect(url, '/sw.js')]);
|
||||||
|
const htmlText = index.body;
|
||||||
|
const swText = sw.body;
|
||||||
|
const tagPattern = new RegExp(`<meta[^>]*name="ytp-build"[^>]*content="${manifest.buildTag}"`);
|
||||||
|
const stamped = index.status === 200 && tagPattern.test(htmlText) && (htmlText.includes(`styles.css?v=${manifest.files['/styles.css']?.h}`) || htmlText.includes(`styles.css?v=${manifest.buildTag}`));
|
||||||
|
const workerStamped = sw.status === 200 && swText.includes(`"${manifest.buildTag}"`) && !swText.includes("typeof __BUILD_TAG__ !== 'undefined'");
|
||||||
|
const compatible = stamped && workerStamped;
|
||||||
|
results.push({ name: `${name}-shell-stamping`, compatible, stamped, workerStamped });
|
||||||
|
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}-shell-stamping`);
|
||||||
|
}
|
||||||
|
const cssHash = baseManifest.body?.files?.['/styles.css']?.h || '';
|
||||||
|
const checks = [
|
||||||
|
['version', '/api/version'],
|
||||||
|
['static', `/styles.css?v=${cssHash}`],
|
||||||
|
['search-local', `/api/search/local?q=${testQuery}`],
|
||||||
|
['search', `/api/search?q=${testQuery}`],
|
||||||
|
['streams', `/api/streams?v=${mediaId}`],
|
||||||
|
['download-prepare', `/api/download/${mediaId}/prepare`],
|
||||||
|
['media-status', `/api/media/${mediaId}/status`],
|
||||||
|
];
|
||||||
|
for (const [name, path] of checks) {
|
||||||
|
const [base, candidate] = await Promise.all([inspect(baseUrl, path), inspect(candidateUrl, path)]);
|
||||||
|
const fields = name === 'static' ? ['contentType', 'cache', 'encoding', 'assetHash'] : ['contentType'];
|
||||||
|
check(name, base, candidate, fields);
|
||||||
|
}
|
||||||
|
const [baseRange, rustRange] = await Promise.all([
|
||||||
|
inspect(baseUrl, `/api/media/${mediaId}?g=1`, { headers: { Range: 'bytes=0-63' } }),
|
||||||
|
inspect(candidateUrl, `/api/media/${mediaId}?g=1`, { headers: { Range: 'bytes=0-63' } }),
|
||||||
|
]);
|
||||||
|
check('media-range', baseRange, rustRange, ['contentType', 'cache', 'range', 'acceptRanges', 'etag']);
|
||||||
|
if (results.some(result => !result.compatible)) process.exitCode = 1;
|
||||||
1
alt/rust/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
27
alt/rust/BENCH.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Local backend A/B benchmark
|
||||||
|
|
||||||
|
Measured 2026-10-14 on the same Linux host, Rust optimized release build vs Bun/Hono, loopback HTTP, Chromium not involved in the load generator. Both services served the identical `frontend/` tree and used a seeded SQLite search row plus a 1 MiB cached-media fixture. The benchmark ran sequentially, four routes, 750 ms per route/concurrency. `responseBytes` are Node fetch's decoded response-body bytes, not raw compressed wire bytes. These are short smoke measurements; use a longer run and production-like media/network before drawing capacity conclusions.
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BENCH_QUERY=rust-backend-parity-20261014 TEST_MEDIA_ID=dQw4w9WgXcQ node alt/bench.mjs http://127.0.0.1:3101 --duration-ms=750
|
||||||
|
BENCH_QUERY=rust-backend-parity-20261014 TEST_MEDIA_ID=dQw4w9WgXcQ node alt/bench.mjs http://127.0.0.1:3100 --duration-ms=750
|
||||||
|
```
|
||||||
|
|
||||||
|
Each cell is `requests/sec (p95 ms)`; all 32 route/concurrency runs reported zero errors.
|
||||||
|
|
||||||
|
| Concurrency | Backend | `/api/version` | CSS asset | Cached search | 1 MiB media range |
|
||||||
|
|---:|---|---:|---:|---:|---:|
|
||||||
|
| 1 | Bun | 2,425 (0.7) | 521 (3.4) | 1,041 (2.5) | 612 (3.5) |
|
||||||
|
| 1 | Rust release | 1,844 (0.9) | 665 (2.0) | 2,269 (0.6) | 480 (3.9) |
|
||||||
|
| 10 | Bun | 7,008 (2.7) | 1,179 (12.4) | 1,623 (17.2) | 685 (27.0) |
|
||||||
|
| 10 | Rust release | 3,872 (3.3) | 1,131 (12.9) | 4,228 (3.1) | 599 (27.4) |
|
||||||
|
| 50 | Bun | 10,033 (11.3) | 1,279 (44.3) | 1,783 (54.7) | 769 (105.5) |
|
||||||
|
| 50 | Rust release | 4,267 (17.4) | 1,199 (52.7) | 4,733 (14.5) | 689 (135.4) |
|
||||||
|
| 200 | Bun | 9,947 (33.7) | 1,333 (203.0) | 1,863 (161.0) | 1,029 (266.3) |
|
||||||
|
| 200 | Rust release | 4,396 (59.8) | 1,328 (202.7) | 4,533 (59.9) | 863 (644.7) |
|
||||||
|
|
||||||
|
The Rust backend led the cached-search route in this short run and was close on static CSS at high concurrency. Bun served `/api/version` and large media ranges faster at high concurrency; Rust's 1 MiB Range p95 widened to 644.7 ms at 200 connections. The shared in-memory static snapshot and boot-time Brotli/gzip encodings avoided disk lookup and per-request recompression, but the Range implementation still streams through Tokio file reads. No result implies production superiority.
|
||||||
|
|
||||||
|
Bun's `yt-dlp` Python worker could not import the system `yt_dlp` package on this host. Bench routes were seeded and do not exercise YouTube upstream latency or media encoding. Docker is unavailable on this host, so image build/compose smoke was not run.
|
||||||
1919
alt/rust/Cargo.lock
generated
Normal file
25
alt/rust/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "ytplayer-rust-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["http1", "tokio", "query", "json"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
brotli = "9"
|
||||||
|
bytes = "1"
|
||||||
|
flate2 = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
hex = "0.4"
|
||||||
|
http = "1"
|
||||||
|
mime_guess = "2"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "gzip", "brotli"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
tower-http = { version = "0.6", features = ["compression-full", "cors", "trace"] }
|
||||||
|
url = "2"
|
||||||
22
alt/rust/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Build from the repository root: docker build -f alt/rust/Dockerfile -t ytplayer-rust .
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY alt/rust/Cargo.toml alt/rust/Cargo.lock ./
|
||||||
|
COPY alt/rust/src ./src
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/build/target \
|
||||||
|
cargo build --release && cp target/release/ytplayer-rust-backend /tmp/ytplayer-rust-backend
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg python3 python3-pip \
|
||||||
|
&& pip3 install --break-system-packages --no-cache-dir yt-dlp \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /tmp/ytplayer-rust-backend /usr/local/bin/ytplayer-rust-backend
|
||||||
|
COPY frontend/ /app/public/
|
||||||
|
RUN mkdir -p /app/data/media
|
||||||
|
ENV PORT=3000 PUBLIC_DIR=/app/public DB_PATH=/app/data/ytplayer.db MEDIA_DIR=/app/data/media \
|
||||||
|
MEDIA_CACHE_MAX_BYTES=10737418240 YTDLP_PATH=/usr/local/bin/yt-dlp FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/usr/local/bin/ytplayer-rust-backend"]
|
||||||
29
alt/rust/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Rust backend experiment
|
||||||
|
|
||||||
|
Drop-in web backend experiment using Axum/Tokio, Tower HTTP compression, reqwest/rustls and SQLite. The existing `frontend/` is copied at image build time and is not modified.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --manifest-path alt/rust/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the service serves `./frontend` when `./public` does not exist. Set `PUBLIC_DIR`, `PORT`, `DB_PATH`, `MEDIA_DIR`, `MEDIA_VOLUME_MARKER`, `MEDIA_CACHE_MAX_BYTES`, `YTDLP_PATH`, `FFMPEG_PATH`, `FFPROBE_PATH`, `APP_VERSION`, `ASSET_HASHING`, and `ASSET_SYNC` as needed. `yt-dlp`, `ffmpeg` and `ffprobe` must be on PATH for live search/streams/media jobs.
|
||||||
|
|
||||||
|
The UI is available at `http://localhost:3000/`; `/api/version` is the health endpoint. Set `BUILD_TAG` only for the legacy `ASSET_HASHING=0` rollback mode. Normal mode computes the tag from runtime assets and stamps each CSS/JS URL.
|
||||||
|
|
||||||
|
## Container
|
||||||
|
|
||||||
|
From the repository root, `docker build -f alt/rust/Dockerfile -t ytplayer-rust .` or `docker compose -f alt/rust/docker-compose.yml up --build`. Compose exposes container port 3000 on external `dokploy-network` and persists SQLite/media data in `/app/data`; it intentionally has no host port mapping or proxy labels.
|
||||||
|
|
||||||
|
## Compare and benchmark
|
||||||
|
|
||||||
|
`node alt/compare.mjs <bun-base-url> [rust-url]` compares status, JSON shape, selected headers, static stamps, and byte-range responses. Set `TEST_MEDIA_ID` to a cached 11-character id for stream/download/range checks. `node alt/rust/browser-smoke.mjs <rust-url>` opens the actual UI in Chromium; set `SMOKE_QUERY` to a cached query to exercise search and card rendering. The benchmark command and measured local results are in [BENCH.md](BENCH.md).
|
||||||
|
|
||||||
|
## Implemented surface and known gaps
|
||||||
|
|
||||||
|
Static SPA delivery, asset manifest/version/SW stamping, compression, search with in-memory/SQLite cache + InnerTube and yt-dlp fallback, stream resolution/warming, YouTube range proxy, validated basic media caching, download preparation/range transfer, and local profile/user persistence are implemented. The notes/admin/P2P/party/remote/upload families and saved-profile password/key protection are intentionally not in this experimental build. Media caching deliberately skips HEVC optimization, advanced retry/backoff lanes, full LRU free-space guards, upload media, chapters, and transcode policy parity; use the production Bun backend for those requirements.
|
||||||
|
|
||||||
|
The cache job is detached from request lifetimes, but the current validation is a reduced safety gate (H.264 + AAC, complete demux, size/duration checks) and is not yet the production validation suite. This is an A/B prototype, not a production replacement.
|
||||||
26
alt/rust/browser-smoke.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const base = process.argv[2] || 'http://127.0.0.1:3000';
|
||||||
|
const query = process.env.SMOKE_QUERY;
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on('pageerror', error => pageErrors.push(error.message));
|
||||||
|
try {
|
||||||
|
const response = await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
assert.equal(response?.status(), 200, 'index page should load');
|
||||||
|
assert.equal(await page.title(), 'YT Player');
|
||||||
|
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), (await (await fetch(new URL('/api/version', base))).json()).buildTag);
|
||||||
|
assert.equal(await page.locator('#searchInput').count(), 1, 'search control should mount');
|
||||||
|
if (query) {
|
||||||
|
await page.locator('#searchInput').fill(query);
|
||||||
|
await page.locator('#searchInput').press('Enter');
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('#cards .card').length > 0, { timeout: 15000 });
|
||||||
|
}
|
||||||
|
assert.deepEqual(pageErrors, [], 'page should not throw during boot or search');
|
||||||
|
console.log(JSON.stringify({ ok: true, url: base, title: await page.title(), searchCards: await page.locator('#cards .card').count(), pageErrors }));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
32
alt/rust/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: alt/rust/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DB_PATH: /app/data/ytplayer.db
|
||||||
|
MEDIA_DIR: /app/data/media
|
||||||
|
MEDIA_VOLUME_MARKER: ${MEDIA_VOLUME_MARKER:-}
|
||||||
|
MEDIA_CACHE_MAX_BYTES: ${MEDIA_CACHE_MAX_BYTES:-10737418240}
|
||||||
|
PUBLIC_DIR: /app/public
|
||||||
|
volumes:
|
||||||
|
- ytplayer-rust-data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/api/version"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ytplayer-rust-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
1599
alt/rust/src/main.rs
Normal file
464
alt/rust/src/manifest.rs
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
fs,
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
|
pub struct AssetFile {
|
||||||
|
pub h: String,
|
||||||
|
pub s: usize,
|
||||||
|
pub g: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AssetSnapshot {
|
||||||
|
pub build_tag: String,
|
||||||
|
pub manifest: Value,
|
||||||
|
pub delivery_files: BTreeMap<String, AssetFile>,
|
||||||
|
pub bytes: BTreeMap<String, Bytes>,
|
||||||
|
pub etags: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash10(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(bytes))[..10].to_string()
|
||||||
|
}
|
||||||
|
fn hash12(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(bytes))[..12].to_string()
|
||||||
|
}
|
||||||
|
fn etag(bytes: &[u8]) -> String {
|
||||||
|
format!("\"{}\"", &hex::encode(Sha256::digest(bytes))[..32])
|
||||||
|
}
|
||||||
|
fn collect(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) -> std::io::Result<()> {
|
||||||
|
let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let url = format!("{prefix}/{name}");
|
||||||
|
if path.is_dir() {
|
||||||
|
collect(&path, &url, out)?;
|
||||||
|
} else {
|
||||||
|
out.insert(url, fs::read(path)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn runtime(path: &str, include: &BTreeSet<String>) -> bool {
|
||||||
|
if include.contains(path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
path != "/admin.html"
|
||||||
|
&& !path.to_ascii_lowercase().split('/').any(|p| p == "perf")
|
||||||
|
&& ![".test.js", ".test.mjs", ".md", ".txt", ".c", ".entry.js"]
|
||||||
|
.iter()
|
||||||
|
.any(|s| path.to_ascii_lowercase().ends_with(s))
|
||||||
|
}
|
||||||
|
fn embed(
|
||||||
|
source: &str,
|
||||||
|
files: &BTreeMap<String, AssetFile>,
|
||||||
|
groups: &Value,
|
||||||
|
tag: &str,
|
||||||
|
asset_sync: bool,
|
||||||
|
hashing: bool,
|
||||||
|
) -> String {
|
||||||
|
let mut page_files = Map::new();
|
||||||
|
if let Some(groups_obj) = groups.as_object() {
|
||||||
|
for group in groups_obj.values() {
|
||||||
|
if let Some(paths) = group.get("files").and_then(Value::as_array) {
|
||||||
|
for p in paths.iter().filter_map(Value::as_str) {
|
||||||
|
if p == "/index.html" || p == "/sw.js" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(file) = files.get(p) {
|
||||||
|
page_files.insert(p.to_string(), json!({"h":if hashing { file.h.as_str() } else { tag },"s":file.s,"g":file.g}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let assets = json!({"buildTag":tag,"groups":groups,"files":page_files,"appCache":asset_sync})
|
||||||
|
.to_string()
|
||||||
|
.replace('<', "\\u003c");
|
||||||
|
let re = Regex::new(r#"(?s)(<script type="application/json" id="ytp-assets">).*?(</script>)"#)
|
||||||
|
.unwrap();
|
||||||
|
re.replace(source, format!("$1{assets}$2")).into_owned()
|
||||||
|
}
|
||||||
|
fn stamp(source: &str, files: &BTreeMap<String, AssetFile>, tag: &str, hashing: bool) -> String {
|
||||||
|
if !hashing {
|
||||||
|
let tag_re = Regex::new(r"(?is)<(?:script|link)\b[^>]*>").unwrap();
|
||||||
|
let attr_re = Regex::new(r#"(?i)\b(href|src)=["']([^"']+\.(?:css|js))["']"#).unwrap();
|
||||||
|
return tag_re
|
||||||
|
.replace_all(
|
||||||
|
&source.replace("__BUILD_TAG__", tag),
|
||||||
|
|caps: ®ex::Captures| {
|
||||||
|
attr_re
|
||||||
|
.replace_all(&caps[0], |a: ®ex::Captures| {
|
||||||
|
let raw = &a[2];
|
||||||
|
if raw.starts_with('/') || raw.starts_with("//") || raw.contains(':') {
|
||||||
|
return a[0].to_string();
|
||||||
|
}
|
||||||
|
let separator = if raw.contains('?') { '&' } else { '?' };
|
||||||
|
format!("{}=\"{}{}v={}\"", &a[1], raw, separator, tag)
|
||||||
|
})
|
||||||
|
.into_owned()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_owned();
|
||||||
|
}
|
||||||
|
let tag_re = Regex::new(r"(?is)<(?:script|link)\b[^>]*>").unwrap();
|
||||||
|
let attr_re = Regex::new(r#"\b(href|src)=(?:"([^"]+)"|'([^']+)')"#).unwrap();
|
||||||
|
let stamped = tag_re
|
||||||
|
.replace_all(source, |caps: ®ex::Captures| {
|
||||||
|
let tag_text = caps.get(0).unwrap().as_str();
|
||||||
|
attr_re
|
||||||
|
.replace_all(tag_text, |a: ®ex::Captures| {
|
||||||
|
let raw = a.get(2).or_else(|| a.get(3)).unwrap().as_str();
|
||||||
|
if raw.contains(":") || raw.starts_with("//") || raw.starts_with('#') {
|
||||||
|
return a[0].to_string();
|
||||||
|
}
|
||||||
|
let url = url::Url::parse(&format!("https://assets.invalid/{raw}"));
|
||||||
|
let Ok(parsed) = url else {
|
||||||
|
return a[0].to_string();
|
||||||
|
};
|
||||||
|
let path = parsed.path();
|
||||||
|
let Some(file) = files.get(path) else {
|
||||||
|
return a[0].to_string();
|
||||||
|
};
|
||||||
|
if !(path.ends_with(".css") || path.ends_with(".js")) || path == "/sw.js" {
|
||||||
|
return a[0].to_string();
|
||||||
|
}
|
||||||
|
let base = raw.split(['?', '#']).next().unwrap_or(raw);
|
||||||
|
let suffix = if let Some((_, query)) = raw.split_once('?') {
|
||||||
|
let rest = query
|
||||||
|
.split('#')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('&')
|
||||||
|
.filter(|p| !p.starts_with("v="))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
if rest.is_empty() {
|
||||||
|
format!("?v={}", file.h)
|
||||||
|
} else {
|
||||||
|
format!("?v={}&{}", file.h, rest)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("?v={}", file.h)
|
||||||
|
};
|
||||||
|
let frag = raw
|
||||||
|
.split_once('#')
|
||||||
|
.map(|(_, f)| format!("#{f}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let quote = if a.get(2).is_some() { "\"" } else { "'" };
|
||||||
|
format!("{}={}{}{}{}{}", &a[1], quote, base, suffix, frag, quote)
|
||||||
|
})
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.into_owned();
|
||||||
|
stamped.replace("__BUILD_TAG__", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(
|
||||||
|
root: &Path,
|
||||||
|
hashing: bool,
|
||||||
|
sync: bool,
|
||||||
|
tag_override: Option<&str>,
|
||||||
|
) -> Result<AssetSnapshot, Box<dyn std::error::Error>> {
|
||||||
|
let mut bytes = BTreeMap::new();
|
||||||
|
collect(root, "", &mut bytes)?;
|
||||||
|
let def: Value = bytes.get("/assets.json").map(|b| serde_json::from_slice(b)).transpose()?.unwrap_or_else(|| json!({"groups":{"core":{"contract":1,"eager":true,"files":bytes.keys().cloned().collect::<Vec<_>>()}}}));
|
||||||
|
let include = def
|
||||||
|
.get("include")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut groups = Map::new();
|
||||||
|
let mut contracts = Map::new();
|
||||||
|
let mut membership = BTreeMap::<String, String>::new();
|
||||||
|
if let Some(group_obj) = def.get("groups").and_then(Value::as_object) {
|
||||||
|
for name in group_obj.keys().cloned().collect::<BTreeSet<_>>() {
|
||||||
|
let group = group_obj[&name].clone();
|
||||||
|
let files = group
|
||||||
|
.get("files")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or("asset group files missing")?;
|
||||||
|
let mut uniq = BTreeSet::new();
|
||||||
|
for f in files.iter().filter_map(Value::as_str) {
|
||||||
|
if !runtime(f, &include) {
|
||||||
|
return Err(format!("excluded runtime file in group {name}: {f}").into());
|
||||||
|
}
|
||||||
|
if !bytes.contains_key(f) {
|
||||||
|
return Err(format!("missing asset group file {f}").into());
|
||||||
|
}
|
||||||
|
if !uniq.insert(f) || membership.insert(f.into(), name.clone()).is_some() {
|
||||||
|
return Err(format!("duplicate asset membership: {f}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contracts.insert(
|
||||||
|
name.clone(),
|
||||||
|
group.get("contract").cloned().unwrap_or(json!(1)),
|
||||||
|
);
|
||||||
|
groups.insert(name, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let groups = Value::Object(groups);
|
||||||
|
let contracts = Value::Object(contracts);
|
||||||
|
let mut files = BTreeMap::new();
|
||||||
|
let mut delivery_files = BTreeMap::new();
|
||||||
|
for (path, content) in &bytes {
|
||||||
|
let f = AssetFile {
|
||||||
|
h: hash10(content),
|
||||||
|
s: content.len(),
|
||||||
|
g: membership
|
||||||
|
.get(path)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "core".into()),
|
||||||
|
};
|
||||||
|
delivery_files.insert(path.clone(), f.clone());
|
||||||
|
if runtime(path, &include) {
|
||||||
|
files.insert(path.clone(), f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut source = String::from_utf8(bytes.get("/index.html").cloned().unwrap_or_default())?;
|
||||||
|
if let Some(app) = bytes.get("/app.js") {
|
||||||
|
source = source.replace("__APP_SCRIPT_HASH__", &STANDARD.encode(Sha256::digest(app)));
|
||||||
|
}
|
||||||
|
let sync = hashing && sync;
|
||||||
|
let embedded = embed(&source, &files, &groups, "__BUILD_TAG__", sync, hashing);
|
||||||
|
let canonical_index = stamp(&embedded, &files, "__BUILD_TAG__", hashing);
|
||||||
|
let mut canonical_files = Map::new();
|
||||||
|
for (path, file) in &files {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
obj.insert("h".into(), json!(file.h));
|
||||||
|
obj.insert("s".into(), json!(file.s));
|
||||||
|
obj.insert("g".into(), json!(file.g));
|
||||||
|
canonical_files.insert(path.clone(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
if hashing && files.contains_key("/index.html") {
|
||||||
|
let original = &files["/index.html"];
|
||||||
|
let mut obj = Map::new();
|
||||||
|
obj.insert("h".into(), json!(hash10(canonical_index.as_bytes())));
|
||||||
|
obj.insert("s".into(), json!(canonical_index.len()));
|
||||||
|
obj.insert("g".into(), json!(original.g));
|
||||||
|
obj.insert("source".into(), json!(original.h));
|
||||||
|
canonical_files.insert("/index.html".into(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
let canonical =
|
||||||
|
json!({"assetSync":sync,"files":canonical_files,"groups":groups,"contracts":contracts});
|
||||||
|
let mut legacy_hasher = Sha256::new();
|
||||||
|
for (path, content) in &bytes {
|
||||||
|
if runtime(path, &include) {
|
||||||
|
legacy_hasher.update(format!("./public{path}").as_bytes());
|
||||||
|
legacy_hasher.update(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let legacy_tag = hex::encode(legacy_hasher.finalize())[..12].to_string();
|
||||||
|
let build_tag = if hashing {
|
||||||
|
hash12(serde_json::to_string(&canonical)?.as_bytes())
|
||||||
|
} else {
|
||||||
|
tag_override.unwrap_or(&legacy_tag).to_string()
|
||||||
|
};
|
||||||
|
let index = embed(
|
||||||
|
&stamp(&source, &files, &build_tag, hashing),
|
||||||
|
&files,
|
||||||
|
&groups,
|
||||||
|
&build_tag,
|
||||||
|
sync,
|
||||||
|
hashing,
|
||||||
|
);
|
||||||
|
let mut sw =
|
||||||
|
String::from_utf8(bytes.get("/sw.js").cloned().unwrap_or_default()).unwrap_or_default();
|
||||||
|
let inject =
|
||||||
|
Regex::new(r"typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'").unwrap();
|
||||||
|
sw = inject.replace(&sw, format!("\"{build_tag}\"")).into_owned();
|
||||||
|
sw = sw.replace(
|
||||||
|
"typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true",
|
||||||
|
if sync { "true" } else { "false" },
|
||||||
|
);
|
||||||
|
if let Some(core) = files.get("/asset-sync-core.js") {
|
||||||
|
sw = sw.replace(
|
||||||
|
"importScripts('/asset-sync-core.js')",
|
||||||
|
&format!("importScripts('/asset-sync-core.js?v={}')", core.h),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !index.is_empty() {
|
||||||
|
files.insert(
|
||||||
|
"/index.html".into(),
|
||||||
|
AssetFile {
|
||||||
|
h: hash10(index.as_bytes()),
|
||||||
|
s: index.len(),
|
||||||
|
g: "core".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !sw.is_empty() {
|
||||||
|
files.insert(
|
||||||
|
"/sw.js".into(),
|
||||||
|
AssetFile {
|
||||||
|
h: hash10(sw.as_bytes()),
|
||||||
|
s: sw.len(),
|
||||||
|
g: "core".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !index.is_empty() {
|
||||||
|
bytes.insert("/index.html".into(), index.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
if !sw.is_empty() {
|
||||||
|
bytes.insert("/sw.js".into(), sw.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
delivery_files.extend(files.clone());
|
||||||
|
let manifest =
|
||||||
|
json!({"buildTag":build_tag,"files":files,"groups":groups,"contracts":contracts});
|
||||||
|
let etags = bytes
|
||||||
|
.iter()
|
||||||
|
.map(|(path, content)| (path.clone(), etag(content)))
|
||||||
|
.collect();
|
||||||
|
let bytes = bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, content)| (path, Bytes::from(content)))
|
||||||
|
.collect();
|
||||||
|
Ok(AssetSnapshot {
|
||||||
|
build_tag,
|
||||||
|
manifest,
|
||||||
|
delivery_files,
|
||||||
|
bytes,
|
||||||
|
etags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_control(
|
||||||
|
path: &str,
|
||||||
|
version: Option<&str>,
|
||||||
|
current: Option<&AssetFile>,
|
||||||
|
hashing: bool,
|
||||||
|
build_tag: &str,
|
||||||
|
) -> &'static str {
|
||||||
|
if !hashing && path == "/index.html" {
|
||||||
|
return "no-cache";
|
||||||
|
}
|
||||||
|
if let Some(version) = version {
|
||||||
|
if current.map(|f| f.h.as_str()) == Some(version) || (!hashing && version == build_tag) {
|
||||||
|
return "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
return if hashing { "no-store" } else { "no-cache" };
|
||||||
|
}
|
||||||
|
if path.starts_with("/fonts/") || path.starts_with("/icons/") {
|
||||||
|
"public, max-age=2592000"
|
||||||
|
} else {
|
||||||
|
"no-cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{cache_control, embed, stamp, AssetFile};
|
||||||
|
use std::{collections::BTreeMap, path::Path};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_frontend_snapshot_has_hashed_assets_and_injected_tags() {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend");
|
||||||
|
let snapshot = super::load(&root, true, true, None).expect("frontend asset manifest");
|
||||||
|
assert_eq!(snapshot.build_tag.len(), 12);
|
||||||
|
assert!(
|
||||||
|
snapshot.manifest["files"]["/app.js"]["h"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.len()
|
||||||
|
== 10
|
||||||
|
);
|
||||||
|
assert!(snapshot.bytes["/index.html"]
|
||||||
|
.windows(snapshot.build_tag.len())
|
||||||
|
.any(|w| w == snapshot.build_tag.as_bytes()));
|
||||||
|
let sw = String::from_utf8(snapshot.bytes["/sw.js"].to_vec()).unwrap();
|
||||||
|
assert!(sw.contains(&snapshot.build_tag));
|
||||||
|
let index = String::from_utf8(snapshot.bytes["/index.html"].to_vec()).unwrap();
|
||||||
|
assert!(index.contains(&format!(
|
||||||
|
"styles.css?v={}",
|
||||||
|
snapshot.manifest["files"]["/styles.css"]["h"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
)));
|
||||||
|
assert!(index.contains(&format!(
|
||||||
|
r#""/app.js":{{"h":"{}""#,
|
||||||
|
snapshot.manifest["files"]["/app.js"]["h"].as_str().unwrap()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_snapshot_uses_legacy_single_tag() {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend");
|
||||||
|
let snapshot =
|
||||||
|
super::load(&root, false, false, Some("legacy-build")).expect("rollback snapshot");
|
||||||
|
assert_eq!(snapshot.build_tag, "legacy-build");
|
||||||
|
let index = String::from_utf8(snapshot.bytes["/index.html"].to_vec()).unwrap();
|
||||||
|
assert!(index.contains("styles.css?v=legacy-build"));
|
||||||
|
assert!(index.contains(r#""/app.js":{"h":"legacy-build""#));
|
||||||
|
assert!(index.contains(r#""appCache":false"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_stamping_only_changes_relative_urls_in_resource_tags() {
|
||||||
|
let input = r#"<link rel="stylesheet" href="styles.css"><script src="/root.js"></script><script src="https://cdn.test/app.js"></script><a href="other.js">link</a>"#;
|
||||||
|
let output = stamp(input, &BTreeMap::new(), "legacy-tag", false);
|
||||||
|
assert!(output.contains(r#"href="styles.css?v=legacy-tag""#));
|
||||||
|
assert!(output.contains(r#"src="/root.js""#));
|
||||||
|
assert!(output.contains(r#"src="https://cdn.test/app.js""#));
|
||||||
|
assert!(output.contains(r#"href="other.js""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_index_embeds_single_build_tag_for_each_asset() {
|
||||||
|
let file = AssetFile {
|
||||||
|
h: "file-hash".into(),
|
||||||
|
s: 10,
|
||||||
|
g: "core".into(),
|
||||||
|
};
|
||||||
|
let files = BTreeMap::from([("/app.js".to_string(), file)]);
|
||||||
|
let groups = serde_json::json!({"core":{"files":["/app.js"]}});
|
||||||
|
let source = r#"<script type="application/json" id="ytp-assets">{}</script>"#;
|
||||||
|
let output = embed(source, &files, &groups, "legacy-tag", false, false);
|
||||||
|
assert!(output.contains(r#""h":"legacy-tag""#));
|
||||||
|
assert!(!output.contains("file-hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_header_matrix_uses_legacy_build_tag_and_file_hashes() {
|
||||||
|
let file = AssetFile {
|
||||||
|
h: "abc123".into(),
|
||||||
|
s: 10,
|
||||||
|
g: "core".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
cache_control(
|
||||||
|
"/app.js",
|
||||||
|
Some("legacy-tag"),
|
||||||
|
Some(&file),
|
||||||
|
false,
|
||||||
|
"legacy-tag"
|
||||||
|
),
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_control("/app.js", Some("old-tag"), Some(&file), false, "legacy-tag"),
|
||||||
|
"no-cache"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_control("/app.js", Some("abc123"), Some(&file), true, "build"),
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_control("/app.js", Some("stale"), Some(&file), true, "build"),
|
||||||
|
"no-store"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
PIANO_WORKER_TOKEN: "${PIANO_WORKER_TOKEN:-}"
|
PIANO_WORKER_TOKEN: "${PIANO_WORKER_TOKEN:-}"
|
||||||
MEDIA_DIR: "/app/bulk/media"
|
MEDIA_DIR: "/app/bulk/media"
|
||||||
MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data"
|
MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data"
|
||||||
MEDIA_CACHE_MAX_BYTES: "268435456000" # 250 GiB of the 458 GiB drive
|
MEDIA_CACHE_MAX_BYTES: "322122547200" # 300 GiB of the 458 GiB drive
|
||||||
MEDIA_MIN_FREE_BYTES: "21474836480" # keep 20 GiB free on the drive
|
MEDIA_MIN_FREE_BYTES: "21474836480" # keep 20 GiB free on the drive
|
||||||
UPLOAD_DIR: "/app/bulk/uploads"
|
UPLOAD_DIR: "/app/bulk/uploads"
|
||||||
UPLOAD_VOLUME_MARKER: "/app/bulk/.hl-data"
|
UPLOAD_VOLUME_MARKER: "/app/bulk/.hl-data"
|
||||||
@@ -29,6 +29,12 @@ services:
|
|||||||
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
|
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
|
||||||
# Dokploy's Environment tab — never commit it. Unset = admin disabled.
|
# Dokploy's Environment tab — never commit it. Unset = admin disabled.
|
||||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||||
|
# Password reset codes for protected profiles (server/profile-recovery.js).
|
||||||
|
SMTP_USER: "${SMTP_USER:-}"
|
||||||
|
SMTP_PASS: "${SMTP_PASS:-}"
|
||||||
|
SMTP_FROM: "${SMTP_FROM:-}"
|
||||||
|
DISCORD_RELAY_URL: "${DISCORD_RELAY_URL:-}"
|
||||||
|
DISCORD_RELAY_TOKEN: "${DISCORD_RELAY_TOKEN:-}"
|
||||||
# Phone remote: "1" = a phone may only pair with a screen on the same
|
# Phone remote: "1" = a phone may only pair with a screen on the same
|
||||||
# network (same public IP as seen by the server). Off by default.
|
# network (same public IP as seen by the server). Off by default.
|
||||||
REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}"
|
REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}"
|
||||||
|
|||||||
37
docs/watch-party-refresh.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Watch party refresh
|
||||||
|
|
||||||
|
Branch: `feat/watch-party-refresh`, based on local `main` at `2309791`.
|
||||||
|
Worktree: `/home/josh/development/personal/ytplayer-watch-party`.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Compact Chat / Queue / Room interface, with the host's Play/Pause, ±10 seconds and Next controls always above the tabs.
|
||||||
|
- Invitation code and Copy link stay visible. Guest playback remains subject to the existing host permission rules.
|
||||||
|
- Room includes audio-only playback (uses the existing player setting), a persistent Gentle sync preference, round-trip connection timing, voice, presentation and lyrics controls.
|
||||||
|
- Gentle sync permits four seconds of drift during playback and limits correction seeks to once every eight seconds; strict mode retains the 1.5-second tolerance. Neither mode corrects playing media while it is already seeking or buffering. Paused state still mirrors the host.
|
||||||
|
- Bounded exponential reconnect with jitter, online recovery, socket identity checks, periodic heartbeat and latency-aware clock offset. Guests wait when the host is absent instead of following a stale timeline.
|
||||||
|
- DOM sections update only when their data changes. Chat appends new messages, preserves history scroll position, and offers Jump to latest. Drafts remain intact through membership/reconnect updates and failed offline sends.
|
||||||
|
- Queue updates preserve the search input and search results. The DJ feature loads when its tab is opened.
|
||||||
|
- Small-screen controls remain above navigation; short viewports use a more compact arrangement. No new runtime dependencies.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
From this worktree, with npm development dependencies installed and Chromium available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test tests/watch-party-browser.cjs
|
||||||
|
node --test frontend/party-dj.test.js frontend/app-seams.test.js
|
||||||
|
cd server
|
||||||
|
bun test ./party.test.js ./party-dj.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser suite starts an isolated loopback Bun relay, opens host and guest browser contexts, and checks controls, draft preservation, buffering/correction cooldown, queue search persistence, chat scroll, offline reconnect, timing and narrow-screen layout. Its playback engine is a deterministic fixture: these checks do not measure actual video throughput, codecs, battery use or live voice quality. Existing server tests cover host authority, seat ownership, chat, DJ, presentations and room lifecycle.
|
||||||
|
|
||||||
|
Audio-only savings depend on whether a separate audio rendition is available; this change does not create lower-bitrate server renditions. Gentle sync deliberately allows more playback drift in exchange for fewer disruptive seeks. Voice still uses the existing STUN-only mesh.
|
||||||
|
|
||||||
|
## Further improvements worth a separate change
|
||||||
|
|
||||||
|
1. Participant readiness/buffering indicators, with an opt-in host “wait for everyone” policy. Needs a bounded readiness protocol and a timeout so one offline guest cannot stall the room.
|
||||||
|
2. Low-bitrate video renditions for server-cached uploads. Requires storage/transcoding budgets and device-compatible codec choices; lowering the quality selector alone cannot reduce a single cached file's bitrate.
|
||||||
|
3. Acknowledged chat delivery and retry IDs. Avoid silent server flood-limit drops and duplicates after reconnect.
|
||||||
|
4. TURN relay and voice connection diagnostics for networks where the existing peer-to-peer voice mesh cannot connect.
|
||||||
1289
frontend/admin.html
@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
|
|||||||
const {join}=require('node:path');
|
const {join}=require('node:path');
|
||||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||||
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
||||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"190834ae6f33a168917416e55799f8a467e0d28f966f636a182745a59ffcac41","Player body unchanged");}
|
||||||
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
||||||
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
||||||
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
||||||
|
|||||||
531
frontend/app.js
@@ -64,6 +64,7 @@ async function webFetch(path, opts = {}) {
|
|||||||
return AsyncGuard.getJson(path, opts, {
|
return AsyncGuard.getJson(path, opts, {
|
||||||
// Some upstream failures are represented by HTTP 200 + {ok:false}; do
|
// Some upstream failures are represented by HTTP 200 + {ok:false}; do
|
||||||
// not expose those transient responses as a manual Retry state either.
|
// not expose those transient responses as a manual Retry state either.
|
||||||
|
...(/^\/api\/search\?/.test(path) ? { timeoutMs: 40000, attempts: 2 } : {}),
|
||||||
retryResult: value => value?.ok === false && /\b(?:408|425|429|5\d\d)\b|temporar|timed?\s*out|network|connection|upstream|unavailable|available right now/i.test(String(value.error || value.message || '')),
|
retryResult: value => value?.ok === false && /\b(?:408|425|429|5\d\d)\b|temporar|timed?\s*out|network|connection|upstream|unavailable|available right now/i.test(String(value.error || value.message || '')),
|
||||||
});
|
});
|
||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
@@ -105,8 +106,17 @@ function saveDataToStorage(jsonStr) {
|
|||||||
// is the browser's own claim; if a copy still fails to play here, the device
|
// is the browser's own claim; if a copy still fails to play here, the device
|
||||||
// is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before.
|
// is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before.
|
||||||
const NO_HEVC_KEY = 'ytpNoHevc';
|
const NO_HEVC_KEY = 'ytpNoHevc';
|
||||||
|
// canPlayType only says the browser can decode HEVC — often in software, which
|
||||||
|
// stutters the picture while the audio stays fine. Ask MediaCapabilities whether
|
||||||
|
// it is hardware-accelerated (powerEfficient) and smooth; remember the answer.
|
||||||
|
const HEVC_HW_KEY = 'ytpHevcHw';
|
||||||
|
try {
|
||||||
|
navigator.mediaCapabilities.decodingInfo({ type: 'file', video: { contentType: 'video/mp4; codecs="hvc1.1.6.L93.B0"', width: 1280, height: 720, bitrate: 1500000, framerate: 30 } })
|
||||||
|
.then((i) => { try { localStorage.setItem(HEVC_HW_KEY, i.supported && i.smooth && i.powerEfficient ? '1' : '0'); } catch { /* storage blocked */ } })
|
||||||
|
.catch(() => {});
|
||||||
|
} catch { /* no MediaCapabilities */ }
|
||||||
function hevcCapable() {
|
function hevcCapable() {
|
||||||
try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ }
|
try { if (localStorage.getItem(NO_HEVC_KEY) === '1' || localStorage.getItem(HEVC_HW_KEY) === '0') return false; } catch { /* storage blocked */ }
|
||||||
try {
|
try {
|
||||||
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
|
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
@@ -161,10 +171,12 @@ const RecentSearches = (() => {
|
|||||||
},
|
},
|
||||||
clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings)
|
clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings)
|
||||||
// Cache the just-fetched result set for `q` (called on every successful search).
|
// Cache the just-fetched result set for `q` (called on every successful search).
|
||||||
cacheResults(q, results, ts) { SearchLibrary.put(q, results, ts); },
|
cacheResults(q, results, ts) { if (results && results.length) SearchLibrary.put(q, results, ts); },
|
||||||
// Promise of the cached {query, results, ts} for `q`, or null.
|
// Promise of the cached {query, results, ts} for `q`, or null. An empty
|
||||||
getResults(q) {
|
// saved set (left by an earlier failed search) counts as no copy.
|
||||||
return SearchLibrary.get(q);
|
async getResults(q) {
|
||||||
|
const hit = await SearchLibrary.get(q);
|
||||||
|
return hit && hit.results && hit.results.length ? hit : null;
|
||||||
},
|
},
|
||||||
// Normalized queries that have a cached result set — one localStorage
|
// Normalized queries that have a cached result set — one localStorage
|
||||||
// read for the whole dropdown, rather than one per row.
|
// read for the whole dropdown, rather than one per row.
|
||||||
@@ -571,8 +583,8 @@ async function opfsClear() {
|
|||||||
let _currentBlobUrl = null;
|
let _currentBlobUrl = null;
|
||||||
|
|
||||||
const API = {
|
const API = {
|
||||||
search: (query, { refresh = false } = {}) => WEB
|
search: (query, { refresh = false, after = 0 } = {}) => WEB
|
||||||
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
|
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}${after ? `&after=${after}` : ''}`)
|
||||||
: call('yt.search', 'yt_search', { query }),
|
: call('yt.search', 'yt_search', { query }),
|
||||||
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
||||||
? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`)
|
? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`)
|
||||||
@@ -856,6 +868,7 @@ function askProfileSecret(name, kind, wrong) {
|
|||||||
showModal('🔒 Protected profile', body, [
|
showModal('🔒 Protected profile', body, [
|
||||||
{ label: 'Cancel', onClick: () => done(null) },
|
{ label: 'Cancel', onClick: () => done(null) },
|
||||||
{ label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } },
|
{ label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } },
|
||||||
|
{ label: 'Forgot?', onClick: async () => { closeModal(); resolve(await resetProfileSecretFlow(name)); } },
|
||||||
{ label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } },
|
{ label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } },
|
||||||
]);
|
]);
|
||||||
setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50);
|
setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50);
|
||||||
@@ -1488,7 +1501,8 @@ function protectProfileFlow() {
|
|||||||
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
|
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
|
||||||
Once protected, the name alone no longer opens it — a device also needs the
|
Once protected, the name alone no longer opens it — a device also needs the
|
||||||
password, the profile key, or the credentials file. <strong>If you lose all of
|
password, the profile key, or the credentials file. <strong>If you lose all of
|
||||||
them the profile can't be opened.</strong>
|
them the profile can't be opened</strong> — unless you add a recovery email or
|
||||||
|
Discord under Recovery… afterwards.
|
||||||
</p>
|
</p>
|
||||||
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
|
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
|
||||||
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
|
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
|
||||||
@@ -1513,6 +1527,7 @@ function protectProfileFlow() {
|
|||||||
} catch (e) { toast('⚠ ' + e.message); }
|
} catch (e) { toast('⚠ ' + e.message); }
|
||||||
} },
|
} },
|
||||||
];
|
];
|
||||||
|
if (prot) actions.splice(1, 0, { label: 'Recovery…', onClick: () => { closeModal(); recoveryContactsFlow(); } });
|
||||||
if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => {
|
if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => {
|
||||||
try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); }
|
try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); }
|
||||||
catch (e) { toast('⚠ ' + e.message); }
|
catch (e) { toast('⚠ ' + e.message); }
|
||||||
@@ -1521,6 +1536,102 @@ function protectProfileFlow() {
|
|||||||
setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50);
|
setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Password reset: recovery email / Discord ----
|
||||||
|
// The server sends a one-time code to the contact saved here; the code plus a
|
||||||
|
// new password replaces the profile's secret (server/profile-recovery.js).
|
||||||
|
async function recoveryContactsFlow() {
|
||||||
|
const name = data.profile && data.profile.name;
|
||||||
|
if (!name || !data.profile.protected) return;
|
||||||
|
let cur;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/recovery?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) });
|
||||||
|
cur = await res.json();
|
||||||
|
if (!res.ok || !cur.ok) throw new Error(cur.error || 'could not load the recovery contacts');
|
||||||
|
} catch (e) { toast('⚠ ' + e.message); return; }
|
||||||
|
const off = (k) => (cur.available && cur.available[k]) ? '' : ' <em style="color:var(--text-dim)">(not switched on for this server yet)</em>';
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||||
|
If you forget the password of <strong>${escapeHtml(name)}</strong>, a reset code can be sent to one of these.
|
||||||
|
</p>
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Email${off('email')}</label>
|
||||||
|
<input id="recEmail" type="email" autocomplete="email" placeholder="you@example.com" value="${escapeHtml(cur.email || '')}" style="margin-bottom:12px" />
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord user id${off('discord')}</label>
|
||||||
|
<input id="recDiscord" inputmode="numeric" autocomplete="off" placeholder="e.g. 123456789012345678" value="${escapeHtml(cur.discord || '')}" style="margin-bottom:12px" />
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord webhook</label>
|
||||||
|
<input id="recWebhook" type="url" autocomplete="off" spellcheck="false" placeholder="https://discord.com/api/webhooks/…" value="${escapeHtml(cur.webhook || '')}" />
|
||||||
|
<p style="margin:8px 0 0;color:var(--text-dim);font-size:12px;line-height:1.5">
|
||||||
|
Discord user id: Settings → Advanced → Developer Mode, then tap your name → Copy User ID; you must share a server with the YT Player bot.
|
||||||
|
Webhook: in a private channel of your own server, Edit Channel → Integrations → Webhooks → New Webhook → Copy Webhook URL. Anyone who can read that channel sees the code, so keep it private.
|
||||||
|
</p>`;
|
||||||
|
showModal('🛟 Recovery contacts', body, [
|
||||||
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
{ label: 'Save', primary: true, onClick: async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/recovery', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, current: ProfileSecret.getFor(name), email: $('recEmail').value.trim(), discord: $('recDiscord').value.trim(), webhook: $('recWebhook').value.trim() }) });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !j.ok) throw new Error(j.error || 'could not save');
|
||||||
|
closeModal(); toast(j.email || j.discord || j.webhook ? 'Recovery contacts saved ✓' : 'Recovery contacts removed');
|
||||||
|
} catch (e) { toast('⚠ ' + e.message); }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot password: pick where the code goes, then enter it with a new password.
|
||||||
|
// Resolves the new password (so the caller can open the profile) or null.
|
||||||
|
async function resetProfileSecretFlow(name) {
|
||||||
|
let info = null;
|
||||||
|
try { info = await (await fetch(`/api/profile/info?name=${encodeURIComponent(name)}`)).json(); } catch { /* offline */ }
|
||||||
|
const rec = info && info.recovery;
|
||||||
|
if (!rec || (!rec.email && !rec.discord && !rec.webhook)) {
|
||||||
|
toast(`⚠ ${name} has no recovery email or Discord set — it opens only with its password, key or credentials file`, { duration: 6000 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const via = await new Promise((resolve) => {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||||
|
Send a reset code for <strong>${escapeHtml(name)}</strong>. It is valid for 15 minutes.</p>`;
|
||||||
|
showModal('🛟 Reset password', body, [
|
||||||
|
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
|
||||||
|
...(rec.webhook ? [{ label: 'Discord channel', primary: !rec.email && !rec.discord, onClick: () => { closeModal(); resolve('webhook'); } }] : []),
|
||||||
|
...(rec.discord ? [{ label: 'Discord DM', primary: !rec.email, onClick: () => { closeModal(); resolve('discord'); } }] : []),
|
||||||
|
...(rec.email ? [{ label: `Email ${rec.emailHint || ''}`.trim(), primary: true, onClick: () => { closeModal(); resolve('email'); } }] : []),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
if (!via) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/reset/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, via }) });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !j.ok) throw new Error(j.error || 'could not send the code');
|
||||||
|
toast(`Code sent to ${j.hint} ✓`, { duration: 4000 });
|
||||||
|
} catch (e) { toast('⚠ ' + e.message, { duration: 6000 }); return null; }
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">Enter the 8-digit code and a new password for <strong>${escapeHtml(name)}</strong>.</p>
|
||||||
|
<input id="resetCode" inputmode="numeric" autocomplete="one-time-code" placeholder="1234-5678" style="margin-bottom:8px" />
|
||||||
|
<input id="resetPw" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
|
||||||
|
<input id="resetPw2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
|
||||||
|
showModal('🛟 Reset password', body, [
|
||||||
|
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
|
||||||
|
{ label: 'Set password', primary: true, onClick: async () => {
|
||||||
|
const code = $('resetCode').value, a = $('resetPw').value, b = $('resetPw2').value;
|
||||||
|
if (a.length < 8) { toast('⚠ Use at least 8 characters'); return; }
|
||||||
|
if (a !== b) { toast('⚠ The two passwords differ'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/reset/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, code, secret: a }) });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !j.ok) throw new Error(j.error || 'could not reset');
|
||||||
|
ProfileSecret.set(name, a, 'password');
|
||||||
|
closeModal(); toast('Password changed ✓', { duration: 4000 }); resolve(a);
|
||||||
|
} catch (e) { toast('⚠ ' + e.message, { duration: 5000 }); }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
setTimeout(() => $('resetCode') && $('resetCode').focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// A small JSON file that signs another device into this profile.
|
// A small JSON file that signs another device into this profile.
|
||||||
async function exportProfileCredentials() {
|
async function exportProfileCredentials() {
|
||||||
const name = data.profile && data.profile.name;
|
const name = data.profile && data.profile.name;
|
||||||
@@ -1783,7 +1894,9 @@ function startCacheResyncWatch() {
|
|||||||
// Pull a result set's artwork into the service worker's thumbnail cache, a few at
|
// Pull a result set's artwork into the service worker's thumbnail cache, a few at
|
||||||
// a time, so a saved search still has pictures offline.
|
// a time, so a saved search still has pictures offline.
|
||||||
function warmSearchThumbs(results) {
|
function warmSearchThumbs(results) {
|
||||||
const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 100);
|
// Only the first screens: warming 100+ images competes with the results
|
||||||
|
// themselves on a slow link.
|
||||||
|
const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 24);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); };
|
const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); };
|
||||||
next(); next(); next();
|
next(); next(); next();
|
||||||
@@ -2013,6 +2126,36 @@ document.addEventListener('play', () => {
|
|||||||
preload(m, { quiet: true });
|
preload(m, { quiet: true });
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// Dropped-frame watchdog: an HEVC server copy that this device decodes in
|
||||||
|
// software drops frames while the audio plays on. Sample the first ~30 s of
|
||||||
|
// each play; if it is clearly struggling, stop asking this device for HEVC and
|
||||||
|
// restart the song on the compatible stream, from where it is.
|
||||||
|
{
|
||||||
|
const dropWatch = { id: '', t: 0, tries: 0 };
|
||||||
|
document.addEventListener('playing', (e) => {
|
||||||
|
const v = e.target;
|
||||||
|
if (!v || v.tagName !== 'VIDEO' || !current || !current.meta || !v.getVideoPlaybackQuality) return;
|
||||||
|
if (!(current.qualities || []).some((q) => q.codec === 'hevc') || !current.serverCached) return;
|
||||||
|
if (dropWatch.id === current.meta.id && dropWatch.tries >= 6) return;
|
||||||
|
if (dropWatch.id !== current.meta.id) { dropWatch.id = current.meta.id; dropWatch.tries = 0; }
|
||||||
|
const id = current.meta.id, at = v.getVideoPlaybackQuality(), t0 = Date.now();
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (!current || !current.meta || current.meta.id !== id || v.paused || Date.now() - t0 > 30000) { clearInterval(check); return; }
|
||||||
|
dropWatch.tries++;
|
||||||
|
const q = v.getVideoPlaybackQuality();
|
||||||
|
const total = q.totalVideoFrames - at.totalVideoFrames, dropped = q.droppedVideoFrames - at.droppedVideoFrames;
|
||||||
|
if (total >= 60 && dropped / total > 0.2) {
|
||||||
|
clearInterval(check);
|
||||||
|
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
|
||||||
|
toast('Video was choppy on this device — switching to the compatible format.', { duration: 4000 });
|
||||||
|
const pos = v.currentTime || 0;
|
||||||
|
if (pos > 1 && data.resumePositions && rememberPosEnabled()) data.resumePositions[id] = Math.floor(pos);
|
||||||
|
Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: true, reveal: false });
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Video editor — cut parts out of a video and save a custom offline copy
|
// Video editor — cut parts out of a video and save a custom offline copy
|
||||||
@@ -2321,7 +2464,9 @@ const Player = {
|
|||||||
_currentBlobUrl = null;
|
_currentBlobUrl = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) {
|
// Party guests follow the host in real time: never hold playback for a
|
||||||
|
// full device-to-device copy (and its accept prompt) — stream instead.
|
||||||
|
if (WEB && !cachedIds.has(videoObj.id) && !Party.guestActive() && window.DirectMedia?.hasSource(videoObj.id)) {
|
||||||
const received = await DirectMedia.obtain(videoObj.id);
|
const received = await DirectMedia.obtain(videoObj.id);
|
||||||
if (seq !== this._loadSeq) return;
|
if (seq !== this._loadSeq) return;
|
||||||
if (!received) { showSpinner(false); return; }
|
if (!received) { showSpinner(false); return; }
|
||||||
@@ -2796,6 +2941,14 @@ const Player = {
|
|||||||
if (this.master.seeking) return;
|
if (this.master.seeking) return;
|
||||||
const drift = this.secondary.currentTime - this.master.currentTime;
|
const drift = this.secondary.currentTime - this.master.currentTime;
|
||||||
const abs = Math.abs(drift);
|
const abs = Math.abs(drift);
|
||||||
|
// iPhone: audio/video currentTime jitter alone exceeds the soft threshold,
|
||||||
|
// so rate-slewing the visible video every tick causes the stutter. Only
|
||||||
|
// snap on a real, sustained desync.
|
||||||
|
if (IS_IOS_DEVICE) {
|
||||||
|
if (abs >= 0.5 && Date.now() - (this._lastSnapAt || 0) > 5000) { this.snapVideoTo(this.master, this.secondary); this._resyncSpeed(); }
|
||||||
|
else if (abs < 0.5) this._resyncSpeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
|
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
|
||||||
// Just snapped: ease in instead of snapping again.
|
// Just snapped: ease in instead of snapping again.
|
||||||
const base = parseFloat(els.speed.value) || 1;
|
const base = parseFloat(els.speed.value) || 1;
|
||||||
@@ -2985,6 +3138,38 @@ function showSpinner(on) {
|
|||||||
els.spinner.classList.toggle('hidden', !on);
|
els.spinner.classList.toggle('hidden', !on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_IOS_DEVICE = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
// Playback telemetry: 1 sample/s for 60 s after playback starts or the app
|
||||||
|
// returns to the foreground, sent once to /api/diag (read via container logs).
|
||||||
|
const Diag = (() => {
|
||||||
|
let timer = null, samples = [], events = [], t0 = 0, tag = '';
|
||||||
|
const ev = (n) => { if (timer) events.push([Math.round(performance.now() - t0), n]); };
|
||||||
|
const q = (V) => { try { const x = V.getVideoPlaybackQuality(); return [x.droppedVideoFrames, x.totalVideoFrames]; } catch { return [-1, -1]; } };
|
||||||
|
function flush() {
|
||||||
|
if (!timer) return;
|
||||||
|
clearInterval(timer); timer = null;
|
||||||
|
const V = els.video;
|
||||||
|
const body = JSON.stringify({ tag, ua: navigator.userAgent, standalone: matchMedia('(display-mode: standalone)').matches,
|
||||||
|
id: current && current.meta && current.meta.id, mode: Player.mode, src: (V.currentSrc || '').replace(/^https?:\/\/[^/]+/, '').slice(0, 80),
|
||||||
|
size: [V.videoWidth, V.videoHeight], hevc: hevcCapable(), samples, events });
|
||||||
|
try { navigator.sendBeacon('/api/diag', new Blob([body], { type: 'application/json' })); } catch {}
|
||||||
|
}
|
||||||
|
function start(why) {
|
||||||
|
flush(); samples = []; events = []; tag = why; t0 = performance.now();
|
||||||
|
timer = setInterval(() => {
|
||||||
|
const V = els.video, m = Player.master, s = Player.secondary;
|
||||||
|
samples.push([samples.length, Player.mode, m === V ? 'v' : 'a', +(m ? m.currentTime : 0).toFixed(2),
|
||||||
|
s ? +(s.currentTime - m.currentTime).toFixed(3) : null, m ? m.playbackRate : 0, q(V), V.readyState, V.paused ? 1 : 0, document.visibilityState === 'visible' ? 1 : 0]);
|
||||||
|
if (samples.length >= 60) flush();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
for (const n of ['waiting', 'stalled', 'seeking', 'seeked', 'ratechange', 'error']) els.video.addEventListener(n, () => ev('v:' + n), true);
|
||||||
|
els.video.addEventListener('playing', () => { if (!timer) start('playing'); }, true);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Player._wantsPlaying) start('return'); else flush(); });
|
||||||
|
return { start, flush };
|
||||||
|
})();
|
||||||
|
|
||||||
function chooseQuality() {
|
function chooseQuality() {
|
||||||
const qs = current.qualities || [];
|
const qs = current.qualities || [];
|
||||||
if (!qs.length) return null;
|
if (!qs.length) return null;
|
||||||
@@ -2993,9 +3178,13 @@ function chooseQuality() {
|
|||||||
const match = qs.find((q) => q.label === pref);
|
const match = qs.find((q) => q.label === pref);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
// auto: best quality at or below 720p, else the lowest available.
|
// auto: best quality at or below 720p (480p on touch phones, where a lossy
|
||||||
|
// link rebuffers the picture while the separate audio keeps playing),
|
||||||
|
// else the lowest available.
|
||||||
|
let cap = 720;
|
||||||
|
try { if (matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) < 600) cap = 480; } catch {}
|
||||||
const sorted = [...qs].sort((a, b) => b.height - a.height);
|
const sorted = [...qs].sort((a, b) => b.height - a.height);
|
||||||
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
|
return sorted.find((q) => q.height <= cap) || sorted[sorted.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQualityMenu() {
|
function buildQualityMenu() {
|
||||||
@@ -4448,11 +4637,42 @@ const Notes = (() => {
|
|||||||
function setStageLyrics(on) {
|
function setStageLyrics(on) {
|
||||||
data.settings.stageLyrics = !!on;
|
data.settings.stageLyrics = !!on;
|
||||||
persist();
|
persist();
|
||||||
|
if (!on) setLandscape(false);
|
||||||
renderStage(true);
|
renderStage(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Landscape lyrics (sing-alongs on a phone): lock the real orientation where
|
||||||
|
// the browser allows it (Android, after fullscreen); iOS can't lock, so the
|
||||||
|
// view is rotated in CSS while the phone is held upright.
|
||||||
|
const landOn = () => !!$('stageLyrics')?.classList.contains('landscape');
|
||||||
|
async function setLandscape(on) {
|
||||||
|
const box = $('stageLyrics'); if (!box || on === landOn()) return;
|
||||||
|
// The player stage is its own stacking context; lift the view to <body> so
|
||||||
|
// it covers the party panel and nav, and put it back on exit.
|
||||||
|
if (on) { setLandscape.home = setLandscape.home || { parent: box.parentNode, next: box.nextSibling }; document.body.append(box); }
|
||||||
|
else if (setLandscape.home) { setLandscape.home.parent.insertBefore(box, setLandscape.home.next); setLandscape.home = null; }
|
||||||
|
box.classList.toggle('landscape', !!on);
|
||||||
|
document.body.classList.toggle('lyrics-landscape', !!on);
|
||||||
|
const b = $('stageLyricsLand');
|
||||||
|
if (b) { b.setAttribute('aria-pressed', String(!!on)); b.textContent = on ? '↺ Exit landscape' : '↻ Landscape'; }
|
||||||
|
try {
|
||||||
|
if (on && document.documentElement.requestFullscreen && screen.orientation?.lock) {
|
||||||
|
if (!document.fullscreenElement) await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
|
||||||
|
await screen.orientation.lock('landscape');
|
||||||
|
} else if (!on) {
|
||||||
|
try { screen.orientation?.unlock?.(); } catch { /* not locked */ }
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch { /* no lock (iOS, desktop): the CSS rotation covers it */ }
|
||||||
|
renderStage(true);
|
||||||
|
setTimeout(() => renderStage(true), 400); // after the rotation settles
|
||||||
|
if (typeof Party !== 'undefined' && Party.renderLyricsToggle) Party.renderLyricsToggle();
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && landOn() && screen.orientation?.type?.startsWith('portrait')) setLandscape(false); });
|
||||||
|
|
||||||
if ($('stageLyricsOff')) {
|
if ($('stageLyricsOff')) {
|
||||||
$('stageLyricsOff').addEventListener('click', () => setStageLyrics(false));
|
$('stageLyricsOff').addEventListener('click', () => setStageLyrics(false));
|
||||||
|
$('stageLyricsLand').addEventListener('click', () => setLandscape(!landOn()));
|
||||||
$('stageLyricsAuto').addEventListener('click', () => {
|
$('stageLyricsAuto').addEventListener('click', () => {
|
||||||
data.settings.stageLyricsAutoscroll = !stageAuto();
|
data.settings.stageLyricsAutoscroll = !stageAuto();
|
||||||
persist();
|
persist();
|
||||||
@@ -4683,7 +4903,7 @@ const Notes = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
|
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
|
||||||
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
|
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn, landscape: setLandscape, landscapeOn: landOn },
|
||||||
jumpChapter,
|
jumpChapter,
|
||||||
openChapterPicker,
|
openChapterPicker,
|
||||||
repeatSection: () => { const cs = chapters().filter(c => c.t <= now()).sort((a, b) => a.t - b.t); Player.seek(abA ?? cs.at(-1)?.t ?? 0); },
|
repeatSection: () => { const cs = chapters().filter(c => c.t <= now()).sort((a, b) => a.t - b.t); Player.seek(abA ?? cs.at(-1)?.t ?? 0); },
|
||||||
@@ -6007,6 +6227,19 @@ const Party = {
|
|||||||
let resume = false;
|
let resume = false;
|
||||||
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
|
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
|
||||||
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
|
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
|
||||||
|
// Invites for this device's online profile: a cheap check that only loads
|
||||||
|
// the party chunk (which shows the invite list) when there is one.
|
||||||
|
const peek = async () => {
|
||||||
|
const name = data.profile && data.profile.name;
|
||||||
|
if (!name || Lazy.loaded('feature:watch-party') || document.visibilityState === 'hidden') return;
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) })).json();
|
||||||
|
if (j && j.ok && j.invites && j.invites.length) { await Lazy.load('feature:watch-party'); window.YT.Party.checkInvites(); }
|
||||||
|
} catch { /* offline */ }
|
||||||
|
};
|
||||||
|
setTimeout(peek, 3000);
|
||||||
|
setInterval(peek, 60_000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') peek(); });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
|
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
|
||||||
@@ -6029,7 +6262,7 @@ function updateProgress() {
|
|||||||
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
|
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
|
||||||
// when "loop current" is on; otherwise B is the effective end of the track and
|
// when "loop current" is on; otherwise B is the effective end of the track and
|
||||||
// we advance per the repeat settings (next track, or wrap when "repeat list").
|
// we advance per the repeat settings (next track, or wrap when "repeat list").
|
||||||
if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) {
|
if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB && !Party.guestActive()) {
|
||||||
if (data.settings.loopOne) {
|
if (data.settings.loopOne) {
|
||||||
Player.seek(abA);
|
Player.seek(abA);
|
||||||
} else if (!advanceQueue()) {
|
} else if (!advanceQueue()) {
|
||||||
@@ -6969,16 +7202,40 @@ function render() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Playlists
|
// Playlists
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function exportPlaylists() {
|
// Playlist files: metadata plus each video's YouTube link, so another device
|
||||||
const json = JSON.stringify(data.playlists, null, 2);
|
// or server can rebuild the list and fetch the videos. Version 1 files (a bare
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
// array of playlists) still import.
|
||||||
|
const PLAYLIST_FILE = 'ytplayer-playlists';
|
||||||
|
const ytIdOf = (v) => {
|
||||||
|
const id = String(v?.id || '').trim();
|
||||||
|
if (/^[A-Za-z0-9_-]{11}$/.test(id) || /^upl_[a-f0-9]{12}$/.test(id)) return id;
|
||||||
|
const m = String(v?.url || v?.link || '').match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
};
|
||||||
|
function exportPlaylists(only = null) {
|
||||||
|
const lists = only ? data.playlists.filter((p) => p.id === only) : data.playlists;
|
||||||
|
const file = {
|
||||||
|
type: PLAYLIST_FILE, version: 2, exportedAt: new Date().toISOString(), origin: location.origin,
|
||||||
|
playlists: lists.map((p) => ({
|
||||||
|
id: p.id, name: p.name,
|
||||||
|
videos: (p.videos || []).map((v) => ({
|
||||||
|
...slim(v),
|
||||||
|
url: /^[A-Za-z0-9_-]{11}$/.test(v.id) ? `https://www.youtube.com/watch?v=${v.id}` : '',
|
||||||
|
...(v.notes ? { notes: v.notes } : {}), ...(v.ab ? { ab: v.ab } : {}),
|
||||||
|
...(v.tags ? { tags: v.tags } : {}), ...(v.key ? { key: v.key } : {}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(file, null, 2) + '\n'], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
const one = only && lists[0] ? '-' + lists[0].name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) : '';
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
|
a.download = `ytplayer-playlists${one}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
toast('Playlists exported');
|
const n = lists.reduce((sum, p) => sum + (p.videos || []).length, 0);
|
||||||
|
toast(`Exported ${lists.length} playlist${lists.length === 1 ? '' : 's'} · ${n} videos`);
|
||||||
}
|
}
|
||||||
function importPlaylists(e) {
|
function importPlaylists(e) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -6986,19 +7243,34 @@ function importPlaylists(e) {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
try {
|
try {
|
||||||
const imported = JSON.parse(ev.target.result);
|
const parsed = JSON.parse(ev.target.result);
|
||||||
if (!Array.isArray(imported)) throw new Error('Invalid format');
|
const incoming = Array.isArray(parsed) ? parsed : parsed && parsed.type === PLAYLIST_FILE && Array.isArray(parsed.playlists) ? parsed.playlists : null;
|
||||||
// Merge: skip duplicates by id, add new ones
|
if (!incoming) throw new Error('not a YT Player playlist file');
|
||||||
const existingIds = new Set(data.playlists.map((p) => p.id));
|
const byId = new Map(data.playlists.map((p) => [p.id, p]));
|
||||||
for (const pl of imported) {
|
const sig = (p) => (p.videos || []).map((v) => v.id).join(',');
|
||||||
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
|
let added = 0, same = 0;
|
||||||
data.playlists.push(pl);
|
const ids = new Set();
|
||||||
existingIds.add(pl.id);
|
for (const raw of incoming) {
|
||||||
}
|
if (!raw || !raw.name || !Array.isArray(raw.videos)) continue;
|
||||||
|
const videos = raw.videos.map((v) => {
|
||||||
|
const id = ytIdOf(v);
|
||||||
|
if (!id) return null;
|
||||||
|
ids.add(id);
|
||||||
|
return { ...slim({ ...v, id, title: v.title || id, thumbnail: v.thumbnail || (/^[A-Za-z0-9_-]{11}$/.test(id) ? `https://i.ytimg.com/vi/${id}/mqdefault.jpg` : '') }),
|
||||||
|
...(v.notes ? { notes: String(v.notes).slice(0, 2000) } : {}), ...(v.ab ? { ab: v.ab } : {}), ...(v.tags ? { tags: v.tags } : {}), ...(v.key ? { key: v.key } : {}) };
|
||||||
|
}).filter(Boolean);
|
||||||
|
const pl = { id: raw.id || uid(), name: String(raw.name).slice(0, 120), videos };
|
||||||
|
const existing = byId.get(pl.id);
|
||||||
|
if (existing && sig(existing) === sig(pl)) { same++; continue; }
|
||||||
|
if (existing) { pl.id = uid(); pl.name = `${pl.name} (imported)`; }
|
||||||
|
data.playlists.push(pl); byId.set(pl.id, pl); added++;
|
||||||
}
|
}
|
||||||
persist();
|
persist();
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
toast(`Imported ${imported.length} playlist(s)`);
|
const ytIds = [...ids].filter((id) => /^[A-Za-z0-9_-]{11}$/.test(id));
|
||||||
|
if (!added) { toast(same ? 'Those playlists are already here' : 'No playlists found in that file'); return; }
|
||||||
|
toast(`Imported ${added} playlist${added === 1 ? '' : 's'}${same ? ` · ${same} already here` : ''}`);
|
||||||
|
if (WEB && ytIds.length) offerServerDownload(ytIds);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('⚠ Failed to import: ' + err.message);
|
toast('⚠ Failed to import: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -7006,6 +7278,28 @@ function importPlaylists(e) {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
e.target.value = ''; // allow re-import of same file
|
e.target.value = ''; // allow re-import of same file
|
||||||
}
|
}
|
||||||
|
// After an import: let the server fetch its own copy of every video, so they
|
||||||
|
// start fast for everyone and keep playing when YouTube is slow.
|
||||||
|
function offerServerDownload(ids) {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `<p class="modal-text">The server can download these <strong>${ids.length}</strong> videos in the background, so they start quickly and keep playing when YouTube is slow. Nothing is saved on this device.</p>`;
|
||||||
|
showModal('Download on the server?', body, [
|
||||||
|
{ label: 'Not now', onClick: closeModal },
|
||||||
|
{ label: 'Download on server', primary: true, onClick: async () => {
|
||||||
|
closeModal();
|
||||||
|
try {
|
||||||
|
let ready = 0, queued = 0;
|
||||||
|
for (let i = 0; i < ids.length; i += 500) {
|
||||||
|
const res = await fetch('/api/media/prefetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ids.slice(i, i + 500) }) });
|
||||||
|
const j = await res.json().catch(() => null);
|
||||||
|
if (!j || !j.ok) throw new Error(j?.error || 'the server refused');
|
||||||
|
ready += j.ready; queued += j.queued;
|
||||||
|
}
|
||||||
|
toast(`Server: ${queued} queued${ready ? ` · ${ready} already there` : ''} — they download in the background`, { duration: 5000 });
|
||||||
|
} catch (err) { toast('⚠ ' + err.message); }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
}
|
||||||
function openImportYouTubeModal() {
|
function openImportYouTubeModal() {
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
@@ -7775,6 +8069,9 @@ function wireUI() {
|
|||||||
collectSearchMetadata(q, instant.results);
|
collectSearchMetadata(q, instant.results);
|
||||||
if (SearchLibrary.isFresh(instant.ts)) { scrollListIntoViewPortrait(); return; }
|
if (SearchLibrary.isFresh(instant.ts)) { scrollListIntoViewPortrait(); return; }
|
||||||
} else {
|
} else {
|
||||||
|
// Never leave the previous search's "Updated … · N results" line over
|
||||||
|
// an empty list while this one loads.
|
||||||
|
$('searchMeta')?.classList.add('hidden');
|
||||||
render();
|
render();
|
||||||
showSearchSkeletons();
|
showSearchSkeletons();
|
||||||
}
|
}
|
||||||
@@ -7783,15 +8080,24 @@ function wireUI() {
|
|||||||
scrollListIntoViewPortrait();
|
scrollListIntoViewPortrait();
|
||||||
const metaBtn = $('searchUpdateBtn');
|
const metaBtn = $('searchUpdateBtn');
|
||||||
if (metaBtn) { metaBtn.disabled = true; metaBtn.textContent = 'Updating…'; }
|
if (metaBtn) { metaBtn.disabled = true; metaBtn.textContent = 'Updating…'; }
|
||||||
let networkDone = false, paintedLocal = false;
|
let networkDone = false, paintedLocal = false, localHits = [];
|
||||||
|
// The server's own matches stay at the top; YouTube's answer is appended
|
||||||
|
// below them (no duplicates), so the list grows instead of being swapped.
|
||||||
|
const merged = (yt) => {
|
||||||
|
if (!localHits.length) return yt;
|
||||||
|
const seen = new Set(localHits.map((v) => v.id));
|
||||||
|
return [...localHits, ...yt.filter((v) => !seen.has(v.id))];
|
||||||
|
};
|
||||||
if (!instant && !refresh) {
|
if (!instant && !refresh) {
|
||||||
// Known videos from the server's metadata store, painted until YouTube answers.
|
// Known videos from the server's metadata store, shown while YouTube searches.
|
||||||
API.searchLocal(q).then((lr) => {
|
API.searchLocal(q).then((lr) => {
|
||||||
if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return;
|
if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return;
|
||||||
paintedLocal = true;
|
paintedLocal = true;
|
||||||
|
localHits = lr.results;
|
||||||
searchResults = lr.results;
|
searchResults = lr.results;
|
||||||
searchAttempted = true;
|
searchAttempted = true;
|
||||||
renderList();
|
renderList();
|
||||||
|
if (els.status) { els.status.classList.remove('hidden'); els.status.textContent = 'Searching YouTube for more…'; }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -7799,7 +8105,8 @@ function wireUI() {
|
|||||||
networkDone = true;
|
networkDone = true;
|
||||||
if (mySeq !== searchSeq) return; // superseded by a newer search
|
if (mySeq !== searchSeq) return; // superseded by a newer search
|
||||||
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
||||||
const results = res.results || [];
|
const results = merged(res.results || []);
|
||||||
|
if (els.status && paintedLocal) els.status.classList.add('hidden');
|
||||||
searchResults = results;
|
searchResults = results;
|
||||||
searchAttempted = true;
|
searchAttempted = true;
|
||||||
renderList();
|
renderList();
|
||||||
@@ -7810,10 +8117,36 @@ function wireUI() {
|
|||||||
warmStreams(results.slice(0, 3).map((r) => r.id));
|
warmStreams(results.slice(0, 3).map((r) => r.id));
|
||||||
if (refresh) toast('Search updated');
|
if (refresh) toast('Search updated');
|
||||||
perfMeasure('ytp:search', 'ytp:search-start');
|
perfMeasure('ytp:search', 'ytp:search-start');
|
||||||
|
// The server answered with YouTube's first page and is still fetching
|
||||||
|
// the rest; keep asking for anything beyond what we have (the server holds
|
||||||
|
// each ask until the next page lands) and append every page as it comes.
|
||||||
|
if (res.partial) (async () => {
|
||||||
|
let have = (res.results || []).length; // what the server has sent so far
|
||||||
|
for (let round = 0; round < 40 && mySeq === searchSeq && view.type === 'search'; round++) {
|
||||||
|
let more;
|
||||||
|
try { more = await API.search(q, { after: have }); } catch { return; /* keep what is shown */ }
|
||||||
|
if (mySeq !== searchSeq || !more?.ok) return;
|
||||||
|
const got = more.results || [];
|
||||||
|
if (got.length > have) {
|
||||||
|
have = got.length;
|
||||||
|
const full = merged(got);
|
||||||
|
const shown = searchResults;
|
||||||
|
const prefix = shown.length <= full.length && shown.every((v, i) => full[i] && full[i].id === v.id);
|
||||||
|
if (prefix && els.cards.children.length) {
|
||||||
|
// Grow the same array, so cards already drawn queue the full list too.
|
||||||
|
const from = shown.length;
|
||||||
|
shown.push(...full.slice(from));
|
||||||
|
appendListCards(shown, from);
|
||||||
|
} else { searchResults = full; renderList(); }
|
||||||
|
showSearchMeta(q, Date.now());
|
||||||
|
}
|
||||||
|
if (!more.partial) { RecentSearches.cacheResults(q, searchResults, Date.now()); return; }
|
||||||
|
}
|
||||||
|
})();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
networkDone = true;
|
networkDone = true;
|
||||||
if (mySeq !== searchSeq) return;
|
if (mySeq !== searchSeq) return;
|
||||||
if (paintedLocal) return; // keep the server's known videos
|
if (paintedLocal) { if (els.status) els.status.classList.add('hidden'); toast('YouTube search failed — showing videos this server knows'); return; } // keep the server's known videos
|
||||||
if (instant) { showSearchMeta(q, instant.ts); if (refresh) toast('Could not update: ' + err.message); return; } // keep the saved copy
|
if (instant) { showSearchMeta(q, instant.ts); if (refresh) toast('Could not update: ' + err.message); return; } // keep the saved copy
|
||||||
els.cards.innerHTML = '';
|
els.cards.innerHTML = '';
|
||||||
els.status.classList.remove('hidden');
|
els.status.classList.remove('hidden');
|
||||||
@@ -7862,7 +8195,20 @@ function wireUI() {
|
|||||||
els.searchRecent.classList.remove('hidden');
|
els.searchRecent.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
|
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
|
||||||
els.searchInput.addEventListener('focus', showRecentSearches);
|
// Only a user's own focus opens the list: never the start-up focus below, nor
|
||||||
|
// a focus iOS restores when the app is reopened. A real tap/click/key must
|
||||||
|
// have happened just before.
|
||||||
|
let lastGestureAt = 0;
|
||||||
|
for (const n of ['pointerdown', 'keydown', 'touchstart']) addEventListener(n, () => { lastGestureAt = Date.now(); }, { capture: true, passive: true });
|
||||||
|
els.searchInput.addEventListener('focus', () => {
|
||||||
|
if (!els.searchInput._bootFocus && Date.now() - lastGestureAt < 1000) showRecentSearches();
|
||||||
|
});
|
||||||
|
// Leaving the app closes the list, so it is not still open on return.
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') hideRecentSearches(); });
|
||||||
|
addEventListener('pagehide', hideRecentSearches);
|
||||||
|
addEventListener('pageshow', () => { if (Date.now() - lastGestureAt > 1000) hideRecentSearches(); });
|
||||||
|
// Already focused (start-up) — a click or tap on the box still opens it.
|
||||||
|
els.searchInput.addEventListener('pointerdown', () => { if (document.activeElement === els.searchInput) showRecentSearches(); });
|
||||||
els.searchInput.addEventListener('input', () => {
|
els.searchInput.addEventListener('input', () => {
|
||||||
if (els.searchInput.value.trim()) hideRecentSearches(); else showRecentSearches();
|
if (els.searchInput.value.trim()) hideRecentSearches(); else showRecentSearches();
|
||||||
});
|
});
|
||||||
@@ -8441,7 +8787,8 @@ function saveAbMarkers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function restoreAbMarkers() {
|
function restoreAbMarkers() {
|
||||||
if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
|
// A party guest follows the host's timeline; their own practice loop would fight it.
|
||||||
|
if (!current || !current.meta || Party.guestActive()) { abA = null; abB = null; updateAbUI(); return; }
|
||||||
// Prefer a marker stored on the current playlist's copy of the video; fall
|
// Prefer a marker stored on the current playlist's copy of the video; fall
|
||||||
// back to the global per-video marker.
|
// back to the global per-video marker.
|
||||||
const entry = currentPlaylistEntry();
|
const entry = currentPlaylistEntry();
|
||||||
@@ -8707,9 +9054,10 @@ async function maybeShowUpdateBanner() {
|
|||||||
prefetchUpdate();
|
prefetchUpdate();
|
||||||
// Ask only once the new build is fully on disk, so "Refresh UI" is an
|
// Ask only once the new build is fully on disk, so "Refresh UI" is an
|
||||||
// instant swap. Until then keep downloading quietly and re-check; if the
|
// instant swap. Until then keep downloading quietly and re-check; if the
|
||||||
// download never completes (8 min) ask anyway — Refresh UI then downloads.
|
// download hasn't completed within 45 s, ask anyway (Update now then
|
||||||
|
// downloads): on a slow link a longer wait looked like no update at all.
|
||||||
_updateSeenAt = _updateSeenAt || Date.now();
|
_updateSeenAt = _updateSeenAt || Date.now();
|
||||||
if (!(await updateDownloaded()) && Date.now() - _updateSeenAt < 8 * 60 * 1000) {
|
if (!(await updateDownloaded()) && Date.now() - _updateSeenAt < 45 * 1000) {
|
||||||
setTimeout(maybeShowUpdateBanner, 15000);
|
setTimeout(maybeShowUpdateBanner, 15000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -8807,23 +9155,83 @@ async function updateFromIcon() {
|
|||||||
await applyUpdate(target);
|
await applyUpdate(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The update card: one non-blocking surface for the whole update, from the
|
||||||
|
// offer through download and install to a retry. It never covers playback or
|
||||||
|
// takes focus, so a song keeps playing while the new build downloads.
|
||||||
|
const UpdateCard = (() => {
|
||||||
|
let el = null, target = null;
|
||||||
|
const ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v4.5h-4.5"/><path d="M12 8v5M9.5 11l2.5 2.5 2.5-2.5"/></svg>';
|
||||||
|
const COPY = {
|
||||||
|
offer: ['A new version is ready', 'It takes a few seconds. Your playlists, history and saved songs stay on this device.'],
|
||||||
|
downloading: ['Downloading the update', 'Keep listening. The app reloads by itself when the download is done.'],
|
||||||
|
ready: ['Installing the update', 'Reloading in a moment.'],
|
||||||
|
error: ['The update didn’t download', ''],
|
||||||
|
};
|
||||||
|
function build() {
|
||||||
|
el = document.createElement('section');
|
||||||
|
el.className = 'update-card';
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
el.setAttribute('aria-live', 'polite');
|
||||||
|
el.setAttribute('aria-labelledby', 'updateCardTitle');
|
||||||
|
el.innerHTML = `<div class="uc-icon">${ICON}</div>
|
||||||
|
<div class="uc-main">
|
||||||
|
<h2 class="uc-title" id="updateCardTitle"></h2>
|
||||||
|
<p class="uc-body"></p>
|
||||||
|
<div class="uc-progress" aria-hidden="true"><span></span></div>
|
||||||
|
<div class="uc-actions">
|
||||||
|
<button type="button" class="uc-later">Later</button>
|
||||||
|
<button type="button" class="uc-go">Update now</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
el.querySelector('.uc-later').addEventListener('click', () => { hide(); setUpdateIcon(true); });
|
||||||
|
el.querySelector('.uc-go').addEventListener('click', () => { if (target) applyUpdate(target); });
|
||||||
|
el.addEventListener('keydown', (e) => { if (e.key === 'Escape' && el.dataset.phase !== 'downloading' && el.dataset.phase !== 'ready') { hide(); setUpdateIcon(true); } });
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
function show(phase, { to, detail } = {}) {
|
||||||
|
if (to) target = to;
|
||||||
|
if (!el) build();
|
||||||
|
const [title, body] = COPY[phase] || COPY.offer;
|
||||||
|
el.dataset.phase = phase;
|
||||||
|
el.querySelector('.uc-title').textContent = title;
|
||||||
|
el.querySelector('.uc-body').textContent = detail || body;
|
||||||
|
el.querySelector('.uc-go').textContent = phase === 'error' ? 'Try again' : 'Update now';
|
||||||
|
const busy = phase === 'downloading' || phase === 'ready';
|
||||||
|
el.querySelectorAll('button').forEach((b) => { b.hidden = busy; });
|
||||||
|
el.setAttribute('aria-busy', busy ? 'true' : 'false');
|
||||||
|
place();
|
||||||
|
requestAnimationFrame(() => el && el.classList.add('is-open'));
|
||||||
|
}
|
||||||
|
// Sit above whatever is docked at the bottom (tab bar, mini player), which
|
||||||
|
// differs by layout and by browser vs installed app.
|
||||||
|
function place() {
|
||||||
|
if (!el) return;
|
||||||
|
let off = 16;
|
||||||
|
for (const sel of ['.bottom-nav', '.mini-bar']) {
|
||||||
|
const n = document.querySelector(sel);
|
||||||
|
if (!n || getComputedStyle(n).display === 'none' || getComputedStyle(n).visibility === 'hidden') continue;
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
if (r.height > 0 && r.top < innerHeight && r.bottom > innerHeight - 4) off = Math.max(off, innerHeight - r.top + 12);
|
||||||
|
}
|
||||||
|
el.style.bottom = off + 'px';
|
||||||
|
}
|
||||||
|
addEventListener('resize', place);
|
||||||
|
function hide() {
|
||||||
|
if (!el) return;
|
||||||
|
const node = el; el = null;
|
||||||
|
node.classList.remove('is-open');
|
||||||
|
setTimeout(() => node.remove(), 260);
|
||||||
|
}
|
||||||
|
return { show, hide, isOpen: () => !!el };
|
||||||
|
})();
|
||||||
|
|
||||||
function showUpdateBanner(target) {
|
function showUpdateBanner(target) {
|
||||||
// Only show the dialog once per page load
|
// Only offer once per page load
|
||||||
if (_updateBannerShown) return;
|
if (_updateBannerShown) return;
|
||||||
_updateBannerShown = true;
|
_updateBannerShown = true;
|
||||||
// However the dialog is dismissed (Later, backdrop), the icon remains.
|
// However the card is dismissed, the icon remains.
|
||||||
setUpdateIcon(true);
|
setUpdateIcon(true);
|
||||||
|
UpdateCard.show('offer', { to: target });
|
||||||
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
|
|
||||||
const body = document.createElement('p');
|
|
||||||
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
|
|
||||||
showModal('⬆ Update available', body, [
|
|
||||||
{ label: 'Later', onClick: () => { closeModal(); setUpdateIcon(true); } },
|
|
||||||
{ label: 'Refresh UI', primary: true, onClick: async () => {
|
|
||||||
closeModal();
|
|
||||||
await applyUpdate(target);
|
|
||||||
}},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracks the current SW registration so applyUpdate() can reach the
|
// Tracks the current SW registration so applyUpdate() can reach the
|
||||||
@@ -8869,10 +9277,12 @@ async function applyUpdate(target, { quiet = false } = {}) {
|
|||||||
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
|
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
|
||||||
onPhase: (phase, status) => {
|
onPhase: (phase, status) => {
|
||||||
if (quiet) return;
|
if (quiet) return;
|
||||||
if (_updateToast) _updateToast.remove();
|
setUpdateIcon(false);
|
||||||
_updateToast = phase === 'ready'
|
UpdateCard.show(phase === 'ready' ? 'ready' : 'downloading', {
|
||||||
? toast(status?.offlineReady === false ? `Applying the update… Offline cache is still preparing (${status.cached}/${status.total}).` : 'Applying the update…', { duration: 20000 })
|
to: target,
|
||||||
: toast('Downloading the update…', { duration: 305000 });
|
detail: phase === 'ready' && status?.offlineReady === false
|
||||||
|
? `Reloading in a moment. Offline songs are still being prepared (${status.cached} of ${status.total}).` : '',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
|
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
|
||||||
cachesApi: caches,
|
cachesApi: caches,
|
||||||
@@ -8892,7 +9302,9 @@ async function applyUpdate(target, { quiet = false } = {}) {
|
|||||||
_updating = false;
|
_updating = false;
|
||||||
writeUpdateState(null);
|
writeUpdateState(null);
|
||||||
if (_updateToast) { _updateToast.remove(); _updateToast = null; }
|
if (_updateToast) { _updateToast.remove(); _updateToast = null; }
|
||||||
toast('⚠ Update didn’t download (' + (err && err.message ? err.message : 'connection problem') + '). Tap the update icon to try again.', { duration: 6000 });
|
const why = err && err.message ? err.message : 'The connection dropped';
|
||||||
|
if (quiet) toast('Update didn’t download (' + why + '). Tap the update icon to try again.', { duration: 6000 });
|
||||||
|
else UpdateCard.show('error', { to: target, detail: `${why.replace(/\.?$/, '.')} Check your connection and try again.` });
|
||||||
setUpdateIcon(true);
|
setUpdateIcon(true);
|
||||||
// Let the next poll re-offer it.
|
// Let the next poll re-offer it.
|
||||||
_updateBannerShown = false;
|
_updateBannerShown = false;
|
||||||
@@ -9277,7 +9689,12 @@ async function boot() {
|
|||||||
|
|
||||||
checkAutoBackup();
|
checkAutoBackup();
|
||||||
render();
|
render();
|
||||||
els.searchInput.focus();
|
// Ready to type on desktop; on touch devices focusing would pop the keyboard.
|
||||||
|
if (!matchMedia('(pointer: coarse)').matches) {
|
||||||
|
els.searchInput._bootFocus = true;
|
||||||
|
els.searchInput.focus({ preventScroll: true });
|
||||||
|
els.searchInput._bootFocus = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Register service worker + ping server with fingerprint (WEB mode only)
|
// Register service worker + ping server with fingerprint (WEB mode only)
|
||||||
if (WEB) checkUpdateOutcome();
|
if (WEB) checkUpdateOutcome();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="YT Player" />
|
<meta name="apple-mobile-web-app-title" content="YT Player" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
|
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self'; frame-src https:;"
|
||||||
/>
|
/>
|
||||||
<title>YT Player</title>
|
<title>YT Player</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
<div id="stageLyricsList" class="sl-list"></div>
|
<div id="stageLyricsList" class="sl-list"></div>
|
||||||
<div class="stage-lyrics-bar">
|
<div class="stage-lyrics-bar">
|
||||||
<button id="stageLyricsAuto" class="np-btn" type="button" aria-pressed="true">⇅ Auto-scroll on</button>
|
<button id="stageLyricsAuto" class="np-btn" type="button" aria-pressed="true">⇅ Auto-scroll on</button>
|
||||||
|
<button id="stageLyricsLand" class="np-btn" type="button" aria-pressed="false">↻ Landscape</button>
|
||||||
<button id="stageLyricsOff" class="np-btn" type="button">📺 Show video</button>
|
<button id="stageLyricsOff" class="np-btn" type="button">📺 Show video</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +507,7 @@
|
|||||||
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
|
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
|
||||||
<!-- Watch party panel (Party in app.js) -->
|
<!-- Watch party panel (Party in app.js) -->
|
||||||
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party">
|
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party">
|
||||||
<div class="pt-head"><span id="ptTitle">🎉 Watch party</span><span id="ptStatus" class="pt-status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
|
<div class="pt-head"><span id="ptTitle">Watch party</span><span id="ptStatus" class="pt-status" role="status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
|
||||||
<div class="pt-body"></div>
|
<div class="pt-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test('download and offline icons never use emoji or text glyphs', () => {
|
|||||||
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
|
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
|
||||||
const allow = new Map([
|
const allow = new Map([
|
||||||
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
|
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
|
||||||
['admin.html', new Set(['<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>'])],
|
['admin.html', new Set(['<div class="keyboard-hint" aria-label="Keyboard shortcuts"><strong>Shortcuts</strong> <kbd>Space</kbd> stamp in Tap-sync · <kbd>↑</kbd><kbd>↓</kbd> select · <kbd>S</kbd> stamp selected · <kbd>K</kbd> play · <kbd>⌘/Ctrl S</kbd> save</div>'])],
|
||||||
]);
|
]);
|
||||||
const failures=[];
|
const failures=[];
|
||||||
for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) {
|
for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) {
|
||||||
|
|||||||
@@ -224,6 +224,7 @@
|
|||||||
let m;
|
let m;
|
||||||
try { m = JSON.parse(e.data); } catch { return; }
|
try { m = JSON.parse(e.data); } catch { return; }
|
||||||
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
|
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
|
||||||
|
if (m.type === 'force-sync') sync();
|
||||||
emit(m);
|
emit(m);
|
||||||
};
|
};
|
||||||
sock.onclose = () => {
|
sock.onclose = () => {
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const ranked = entries => [...entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.length - a.votes.length);
|
const ranked = entries => [...entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.length - a.votes.length);
|
||||||
function render(container, { state, host, pid, send, search }) {
|
function render(container, { state, host, pid, send, search }) {
|
||||||
|
if (container._partyDJ && container._partyDJ.enabled === state.enabled) {
|
||||||
|
container._partyDJ.update(state); return;
|
||||||
|
}
|
||||||
|
container.replaceChildren();
|
||||||
const doc = container.ownerDocument;
|
const doc = container.ownerDocument;
|
||||||
if (host) { const label = doc.createElement('label'); label.className = 'pt-toggle'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = state.enabled; toggle.onchange = () => send({ type: 'dj', action: 'enable', enabled: toggle.checked }); label.append(toggle, ' Party DJ mode'); container.append(label); }
|
if (host) { const label = doc.createElement('label'); label.className = 'pt-toggle'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = state.enabled; toggle.onchange = () => send({ type: 'dj', action: 'enable', enabled: toggle.checked }); label.append(toggle, ' Party DJ mode'); container.append(label); }
|
||||||
if (!state.enabled) return;
|
if (!state.enabled) { container._partyDJ = { enabled: false, update() {} }; return; }
|
||||||
const heading = doc.createElement('h3'); heading.textContent = 'Party Queue'; container.append(heading);
|
const heading = doc.createElement('h3'); heading.textContent = 'Party Queue'; container.append(heading);
|
||||||
const form = doc.createElement('div'); form.className = 'party-dj-search';
|
const form = doc.createElement('div'); form.className = 'party-dj-search';
|
||||||
const input = doc.createElement('input'); input.type = 'search'; input.placeholder = 'Suggest a song'; input.setAttribute('aria-label', 'Search for a party suggestion');
|
const input = doc.createElement('input'); input.type = 'search'; input.placeholder = 'Suggest a song'; input.setAttribute('aria-label', 'Search for a party suggestion');
|
||||||
@@ -12,11 +16,17 @@
|
|||||||
let request = 0;
|
let request = 0;
|
||||||
button.onclick = async () => { const query = input.value.trim(); if (!query) return; const token = ++request; button.disabled = true; results.textContent = 'Searching…'; try { const videos = await search(query); if (!container.isConnected || token !== request) return; results.replaceChildren(); for (const video of videos.slice(0, 8)) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = `Suggest: ${video.title || video.id}`; b.onclick = () => send({ type: 'dj', action: 'suggest', video }); results.append(b); } if (!videos.length) results.textContent = 'No matches.'; } catch { results.textContent = 'Search unavailable. Try again.'; } finally { button.disabled = false; } };
|
button.onclick = async () => { const query = input.value.trim(); if (!query) return; const token = ++request; button.disabled = true; results.textContent = 'Searching…'; try { const videos = await search(query); if (!container.isConnected || token !== request) return; results.replaceChildren(); for (const video of videos.slice(0, 8)) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = `Suggest: ${video.title || video.id}`; b.onclick = () => send({ type: 'dj', action: 'suggest', video }); results.append(b); } if (!videos.length) results.textContent = 'No matches.'; } catch { results.textContent = 'Search unavailable. Try again.'; } finally { button.disabled = false; } };
|
||||||
input.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }; form.append(input, button); container.append(form, results);
|
input.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }; form.append(input, button); container.append(form, results);
|
||||||
for (const entry of ranked(state.entries)) {
|
const rows = doc.createElement('div'); container.append(rows);
|
||||||
|
const update = next => {
|
||||||
|
rows.replaceChildren();
|
||||||
|
for (const entry of ranked(next.entries)) {
|
||||||
const row = doc.createElement('div'); row.className = 'party-dj-row'; const title = doc.createElement('b'); title.textContent = entry.video.title; row.append(title);
|
const row = doc.createElement('div'); row.className = 'party-dj-row'; const title = doc.createElement('b'); title.textContent = entry.video.title; row.append(title);
|
||||||
const status = doc.createElement('small'); status.textContent = entry.approved ? 'Upcoming' : 'Awaiting host approval'; row.append(status);
|
const status = doc.createElement('small'); status.textContent = entry.approved ? 'Upcoming' : 'Awaiting host approval'; row.append(status);
|
||||||
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } container.append(row);
|
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } rows.append(row);
|
||||||
}
|
}
|
||||||
|
if (!next.entries.length) { const empty = doc.createElement('p'); empty.className = 'pt-hint'; empty.textContent = 'The queue is open. Suggest the first video.'; rows.append(empty); }
|
||||||
|
};
|
||||||
|
container._partyDJ = { enabled: true, update }; update(state);
|
||||||
}
|
}
|
||||||
function register(open) { root.SettingsSections.register({ id: 'party-dj', title: 'Watch party', cluster: 'Live & worship', summary: () => 'Shared suggestions · host approval', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'Party DJ mode is off until the host enables it. Guests suggest songs and vote once per song; approved songs play in vote order.'; const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open watch party'; b.onclick = open; container.append(p, b); } }); }
|
function register(open) { root.SettingsSections.register({ id: 'party-dj', title: 'Watch party', cluster: 'Live & worship', summary: () => 'Shared suggestions · host approval', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'Party DJ mode is off until the host enables it. Guests suggest songs and vote once per song; approved songs play in vote order.'; const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open watch party'; b.onclick = open; container.append(p, b); } }); }
|
||||||
const api = { ranked, render, register }; if (typeof module !== 'undefined') module.exports = api; else root.PartyDJ = api;
|
const api = { ranked, render, register }; if (typeof module !== 'undefined') module.exports = api; else root.PartyDJ = api;
|
||||||
|
|||||||
@@ -1039,6 +1039,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search/list cards: the server already holds a copy (fast start, no YouTube). */
|
||||||
|
.thumb .server-badge {
|
||||||
|
position: absolute; left: 5px; top: 5px; display: inline-flex; align-items: center; gap: 3px;
|
||||||
|
padding: 2px 6px 2px 4px; border-radius: 5px; background: rgba(0,0,0,0.82); color: #fff;
|
||||||
|
font: 600 10px/1.2 var(--ui, system-ui), sans-serif; letter-spacing: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.thumb .server-badge svg { width: 11px; height: 11px; fill: none; stroke: var(--ok, #4ade80); stroke-width: 2.2; stroke-linecap: round; }
|
||||||
.card-info { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
.card-info { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 13.5px; font-weight: 600; line-height: 1.32;
|
font-size: 13.5px; font-weight: 600; line-height: 1.32;
|
||||||
@@ -1857,6 +1864,73 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
:root[data-density="compact"] .list-header { padding: 16px 16px 10px; }
|
:root[data-density="compact"] .list-header { padding: 16px 16px 10px; }
|
||||||
:root[data-density="compact"] .topbar { padding: 11px 18px; }
|
:root[data-density="compact"] .topbar { padding: 11px 18px; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* UPDATE CARD — non-blocking: offer, downloading, installing, retry
|
||||||
|
* ========================================================================== */
|
||||||
|
.update-card {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 455; /* above Service mode and the report sheet, below toasts */
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 18px 48px -12px rgba(0, 0, 0, 0.55), 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
transition: opacity 220ms cubic-bezier(0.16, 1, 0.3, 1), transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.update-card.is-open { opacity: 1; transform: none; }
|
||||||
|
.update-card .uc-icon {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
|
||||||
|
color: var(--accent-bright, var(--accent));
|
||||||
|
}
|
||||||
|
.update-card .uc-icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.update-card[data-phase="downloading"] .uc-icon svg,
|
||||||
|
.update-card[data-phase="ready"] .uc-icon svg { animation: uc-turn 1.6s linear infinite; }
|
||||||
|
.update-card[data-phase="error"] .uc-icon { background: color-mix(in srgb, var(--text-dim) 14%, var(--bg-2)); color: var(--text-2); }
|
||||||
|
.update-card .uc-main { min-width: 0; }
|
||||||
|
.update-card .uc-title { margin: 1px 0 4px; font-size: 16px; font-weight: 650; line-height: 1.3; letter-spacing: -0.01em; text-wrap: balance; }
|
||||||
|
.update-card .uc-body { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-2); text-wrap: pretty; }
|
||||||
|
.update-card .uc-progress { display: none; height: 4px; margin-top: 14px; border-radius: 4px; overflow: hidden; background: var(--bg-3); }
|
||||||
|
.update-card .uc-progress span { display: block; width: 38%; height: 100%; border-radius: inherit; background: var(--accent); animation: uc-slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
||||||
|
.update-card[data-phase="downloading"] .uc-progress,
|
||||||
|
.update-card[data-phase="ready"] .uc-progress { display: block; }
|
||||||
|
.update-card[data-phase="ready"] .uc-progress span { width: 100%; animation: none; }
|
||||||
|
.update-card .uc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||||
|
.update-card .uc-actions button {
|
||||||
|
min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px;
|
||||||
|
font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.update-card .uc-actions button[hidden] { display: none; }
|
||||||
|
.update-card .uc-actions button:active { transform: scale(0.97); }
|
||||||
|
.update-card .uc-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.update-card .uc-later { background: transparent; color: var(--text-2); }
|
||||||
|
.update-card .uc-later:hover { background: var(--bg-3); color: var(--text); }
|
||||||
|
.update-card .uc-go { background: var(--accent-deep, var(--accent)); color: var(--on-primary, #fff); }
|
||||||
|
.update-card .uc-go:hover { background: color-mix(in srgb, var(--accent-deep, var(--accent)) 86%, #000); }
|
||||||
|
@keyframes uc-slide { from { transform: translateX(-100%); } to { transform: translateX(265%); } }
|
||||||
|
@keyframes uc-turn { to { transform: rotate(360deg); } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.update-card { right: 16px; left: 16px; width: auto; bottom: calc(16px + env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.update-card { transition: opacity 120ms linear; transform: none; }
|
||||||
|
.update-card .uc-icon svg { animation: none !important; }
|
||||||
|
.update-card .uc-progress span { width: 100%; animation: none; opacity: 0.6; }
|
||||||
|
}
|
||||||
|
:root[data-motion="reduced"] .update-card .uc-icon svg,
|
||||||
|
:root[data-motion="reduced"] .update-card .uc-progress span { animation: none !important; }
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
* PERFORMANCE MODE — drop expensive paints (grain, blur, big glows)
|
* PERFORMANCE MODE — drop expensive paints (grain, blur, big glows)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
@@ -2267,6 +2341,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
.player-pane:not(.empty) .player-stage {
|
.player-pane:not(.empty) .player-stage {
|
||||||
box-shadow: 0 0 0 1px var(--line) inset;
|
box-shadow: 0 0 0 1px var(--line) inset;
|
||||||
}
|
}
|
||||||
|
/* The per-layout themes (html[data-layout=...] .topbar etc.) out-rank the
|
||||||
|
* rules above and bring 20px blurs back over the playing video, as do the
|
||||||
|
* lyrics bar buttons and section rail. Nothing on a phone PWA may blur. */
|
||||||
|
*, *::before, *::after {
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
@@ -3990,12 +4071,72 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
}
|
}
|
||||||
.party-panel.min { bottom: auto; }
|
.party-panel.min { bottom: auto; }
|
||||||
.party-panel.min .pt-body { display: none; }
|
.party-panel.min .pt-body { display: none; }
|
||||||
|
.pt-present { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pt-present:empty { display: none; }
|
||||||
|
.pt-pq { display: grid; gap: 6px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
|
.pt-pq-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.pt-pq-row { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 10px; background: var(--bg-1); }
|
||||||
|
.pt-pq-row.live { box-shadow: inset 0 0 0 1px var(--accent, #f06b53); }
|
||||||
|
.pt-pq-name { flex: 1; min-width: 0; display: grid; overflow: hidden; }
|
||||||
|
.pt-pq-name, .pt-pq-name small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.pt-pq-name small { color: var(--text-dim); font-size: 11px; }
|
||||||
|
.pt-pq-row .np-btn { flex: none; min-height: 36px; padding-inline: 10px; }
|
||||||
|
.pt-pq-empty { margin: 0; font-size: 12px; color: var(--text-2); }
|
||||||
|
.pt-invite { display: flex; gap: 6px; }
|
||||||
|
.pt-invite input { flex: 1; min-width: 0; }
|
||||||
|
.pt-uninvite { margin-left: 4px; min-width: 28px; min-height: 28px; border: 0; background: none; color: var(--text-2); font-size: 16px; cursor: pointer; }
|
||||||
|
.pt-invites { display: grid; gap: 8px; margin-top: 4px; }
|
||||||
|
.pt-invite-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||||
|
.pt-invite-row > div { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||||
|
.pt-invite-row small { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-invite-row .rv-primary { flex: none; min-height: 44px; }
|
||||||
|
/* Shared presentation: fills the screen beside the party panel (below it on phones). */
|
||||||
|
.party-present {
|
||||||
|
position: fixed; inset: 12px 372px 12px 12px; z-index: 155;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.party-present.hidden { display: none; }
|
||||||
|
.pp-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
|
||||||
|
.pp-what { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: var(--text); }
|
||||||
|
.pp-pages { display: inline-flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text-2); }
|
||||||
|
.pp-pages[hidden], .pp-pages button[hidden] { display: none; }
|
||||||
|
.pp-bar .np-btn { min-height: 40px; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.pp-frame { flex: 1; width: 100%; border: 0; background: #fff; }
|
||||||
|
.pp-hint { margin: 0; padding: 6px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-2); }
|
||||||
|
.pp-hint[hidden], .pp-frame[hidden], .pp-rtools[hidden] { display: none; }
|
||||||
|
.pp-rtools { display: inline-flex; gap: 6px; }
|
||||||
|
.pp-reader { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; background: var(--bg); }
|
||||||
|
.pp-reader[hidden] { display: none; }
|
||||||
|
.pp-article { max-width: 68ch; margin: 0 auto; padding: 24px 20px 64px; font: 400 var(--pp-size, 20px)/1.65 var(--ui, system-ui), sans-serif; color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
.pp-article h1 { font-family: var(--display, inherit); font-size: 1.6em; line-height: 1.15; margin: 0 0 .3em; text-wrap: balance; }
|
||||||
|
.pp-article h2, .pp-article h3 { line-height: 1.25; margin: 1.4em 0 .4em; }
|
||||||
|
.pp-article .pp-by { margin: 0 0 1.4em; color: var(--text-2); font-size: .8em; }
|
||||||
|
.pp-article p, .pp-article li { margin: 0 0 .9em; }
|
||||||
|
.pp-article img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||||
|
.pp-article a { color: var(--accent, #f06b53); text-underline-offset: 3px; }
|
||||||
|
.pp-article blockquote { margin: 1em 0; padding-left: 1em; border-left: 2px solid var(--line); color: var(--text-2); }
|
||||||
|
.pp-article pre { overflow-x: auto; font-size: .8em; }
|
||||||
|
.pp-article table { display: block; overflow-x: auto; font-size: .85em; border-collapse: collapse; }
|
||||||
|
.pp-article td, .pp-article th { padding: 4px 8px; border: 1px solid var(--line); }
|
||||||
|
.pp-msg { padding: 32px 20px 0; text-align: center; color: var(--text-2); }
|
||||||
|
.party-present.landscape { inset: 0; border-radius: 0; z-index: 900; }
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.party-present.landscape { inset: auto; top: 0; left: 100vw; width: 100vh; width: 100dvh; height: 100vw; transform-origin: top left; transform: rotate(90deg); }
|
||||||
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.pp-bar { flex-wrap: wrap; }
|
||||||
|
.pp-what { flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.party-present { inset: calc(8px + env(safe-area-inset-top, 0px)) 8px calc(8px + env(safe-area-inset-bottom, 0px)) 8px; z-index: 165; }
|
||||||
|
}
|
||||||
.pt-head { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
.pt-head { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||||
.pt-head .rv-icon { width: 30px; height: 30px; font-size: 12px; margin-left: auto; }
|
.pt-head .rv-icon { width: 30px; height: 30px; font-size: 12px; margin-left: auto; }
|
||||||
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
||||||
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
||||||
.pt-row input, .pt-msg, .pt-name {
|
.pt-row input, .pt-msg, .pt-name, .pt-invite-name {
|
||||||
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
|
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -4090,6 +4231,17 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.stage-lyrics-bar { opacity: 1; }
|
.stage-lyrics-bar { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
/* Landscape lyrics: the whole screen. Held upright (iOS can't lock the
|
||||||
|
orientation), the view is turned a quarter so it reads sideways. */
|
||||||
|
.stage-lyrics.landscape { position: fixed; inset: 0; z-index: 900; background: #000; }
|
||||||
|
.stage-lyrics.landscape .stage-lyrics-bar { padding-bottom: calc(8px + env(safe-area-inset-bottom)); }
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.stage-lyrics.landscape {
|
||||||
|
inset: auto; top: 0; left: 100vw; width: 100vh; width: 100dvh; height: 100vw;
|
||||||
|
transform-origin: top left; transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.lyrics-landscape { overflow: hidden; }
|
||||||
|
|
||||||
/* --- "syncing" spinner beside the Playlists header -----------------------
|
/* --- "syncing" spinner beside the Playlists header -----------------------
|
||||||
Playlists paint from localStorage on the first frame; this says the
|
Playlists paint from localStorage on the first frame; this says the
|
||||||
@@ -4399,3 +4551,78 @@ html[data-layout] .search-btn:active { transform: scale(.98); }
|
|||||||
.search-btn::before { width: 15px; height: 15px; }
|
.search-btn::before { width: 15px; height: 15px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .search-btn { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .search-btn { transition: none; } }
|
||||||
|
|
||||||
|
/* Watch party: compact cinema companion, with transport always in reach. */
|
||||||
|
.party-panel { width: 360px; padding: 16px; gap: 12px; box-shadow: none; background: var(--bg-1); }
|
||||||
|
.party-panel .pt-head { flex-wrap: wrap; gap: 4px 8px; }
|
||||||
|
.party-panel #ptTitle { flex: 1; font-size: 16px; }
|
||||||
|
.party-panel .pt-head .rv-icon { min-width: 44px; height: 44px; order: 1; }
|
||||||
|
.party-panel .pt-status { order: 2; flex-basis: 100%; font: inherit; font-size: 12px; white-space: normal; }
|
||||||
|
.party-panel .pt-body { overflow: hidden; gap: 12px; }
|
||||||
|
.party-panel button, .party-panel input:not([type="checkbox"]) { min-height: 44px; }
|
||||||
|
.party-panel button:focus-visible, .party-panel input:focus-visible, .party-panel [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.party-panel [hidden] { display: none !important; }
|
||||||
|
.pt-session { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pt-session > div { flex: 1; min-width: 0; }
|
||||||
|
.pt-caption { display: block; color: var(--text-dim); font-size: 12px; margin-bottom: 4px; }
|
||||||
|
#ptRoomCode { font: 600 18px var(--mono); letter-spacing: .08em; }
|
||||||
|
.pt-playing { position: relative; padding: 12px; background: var(--bg-2); border-radius: 8px; }
|
||||||
|
#ptNow { display: block; font-size: 14px; line-height: 1.45; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-playing:has(.pt-resync:not([hidden])) { padding-right: 88px; }
|
||||||
|
.pt-resync { position: absolute; right: 8px; top: 8px; }
|
||||||
|
.pt-transport { display: flex; gap: 8px; }
|
||||||
|
.pt-transport button { flex: 1; min-width: 0; }
|
||||||
|
.pt-transport [data-act="playback"] { background: var(--accent); color: var(--on-play, var(--bg-1)); border-color: var(--accent); }
|
||||||
|
.pt-tabs { display: flex; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||||
|
.pt-tabs button { flex: 1; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-dim); font: inherit; font-size: 14px; cursor: pointer; }
|
||||||
|
.pt-tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
|
||||||
|
.pt-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; overscroll-behavior: contain; }
|
||||||
|
.pt-pane[data-pane="chat"] { overflow: hidden; }
|
||||||
|
.party-panel .pt-chat { flex: 1; min-height: 48px; max-height: none; background: transparent; border: 0; padding: 4px 0; gap: 12px; overflow-anchor: none; }
|
||||||
|
.pt-line { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||||
|
.pt-line span { padding: 8px 12px; border-radius: 8px; background: var(--bg-2); max-width: 100%; }
|
||||||
|
.pt-line.me { align-items: flex-end; }
|
||||||
|
.pt-line b { font-size: 12px; }
|
||||||
|
.pt-sys { padding: 4px 0; }
|
||||||
|
.pt-send { flex-shrink: 0; }
|
||||||
|
.pt-new { align-self: center; font-size: 12px; }
|
||||||
|
.pt-hint, .pt-empty { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 0; }
|
||||||
|
.pt-empty { margin: auto; padding: 24px 8px; text-align: center; }
|
||||||
|
.pt-pane h3 { font-size: 14px; margin: 4px 0; }
|
||||||
|
.pt-pane h3 span { color: var(--text-dim); font-weight: 400; }
|
||||||
|
.pt-options, .pt-host { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.pt-options { border-block: 1px solid var(--line-soft); padding: 12px 0; }
|
||||||
|
.party-panel .pt-toggle { min-height: 44px; font-size: 14px; gap: 8px; }
|
||||||
|
.pt-toggle input { width: 18px; height: 18px; accent-color: var(--accent); }
|
||||||
|
.pt-lyrics-controls, .pt-invited { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.party-panel .pt-leave { margin-top: 8px; color: var(--text-2); }
|
||||||
|
.pt-lobby { overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 8px 0; }
|
||||||
|
.pt-lobby h2 { font-size: 26px; line-height: 1.2; letter-spacing: -.02em; margin: 0; }
|
||||||
|
.pt-lobby p { font-size: 14px; line-height: 1.6; color: var(--text-dim); margin: 0; }
|
||||||
|
.pt-field { display: flex; flex-direction: column; gap: 8px; color: var(--text-2); font-size: 12px; }
|
||||||
|
.pt-lobby .pt-or { padding-top: 8px; border-top: 1px solid var(--line-soft); }
|
||||||
|
.pt-dj { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.pt-dj button, .pt-dj input { font: inherit; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||||||
|
.pt-dj .party-dj-search { display: flex; gap: 8px; }
|
||||||
|
.pt-dj .party-dj-search input { min-width: 0; width: 100%; }
|
||||||
|
.pt-dj .party-dj-row { display: flex; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid var(--line-soft); padding: 12px 0; }
|
||||||
|
.pt-dj .party-dj-row b, .pt-dj .party-dj-row small { flex-basis: 100%; overflow-wrap: anywhere; }
|
||||||
|
.pt-dj .party-dj-row small { color: var(--text-dim); }
|
||||||
|
@media (min-width: 900px) { .party-present { right: 392px; } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.party-panel { width: auto; padding: 12px; height: 62dvh; max-height: min(62dvh, calc(100dvh - max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) - 72px)); }
|
||||||
|
.party-panel.min { height: auto; }
|
||||||
|
.party-panel .pt-body { overflow: hidden; }
|
||||||
|
.party-panel .pt-session { gap: 4px; }
|
||||||
|
.party-panel .pt-session .np-btn { padding-inline: 8px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .party-panel *, .party-panel *::before, .party-panel *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.party-panel.min { bottom: calc(max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) + 8px); }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) and (max-height: 700px) {
|
||||||
|
.party-panel:not(.min) { height: calc(100dvh - max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) - 24px); max-height: none; }
|
||||||
|
.party-panel, .party-panel .pt-body { gap: 8px; }
|
||||||
|
.pt-playing .pt-caption, .pt-session .pt-caption { display: none; }
|
||||||
|
.pt-playing { padding-block: 8px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,7 +201,10 @@ html[data-layout="classic"][data-density="compact"] .thumb { width: 88px; }
|
|||||||
html[data-layout="classic"] .search-form { gap: 6px; }
|
html[data-layout="classic"] .search-form { gap: 6px; }
|
||||||
html[data-layout="classic"] .search-btn { padding-inline: 12px; font-size: 13px; }
|
html[data-layout="classic"] .search-btn { padding-inline: 12px; font-size: 13px; }
|
||||||
html[data-layout="classic"] #searchInput { padding-inline: 10px; }
|
html[data-layout="classic"] #searchInput { padding-inline: 10px; }
|
||||||
html[data-layout="classic"] .sidebar { padding-top: calc(24px + env(safe-area-inset-top)); padding-bottom: calc(24px + env(safe-area-inset-bottom)); z-index: 350; }
|
/* The bottom nav and mini player float over the drawer's bottom edge: end
|
||||||
|
the scroll above them, or the last item (Audio-only) sits underneath and
|
||||||
|
springs back out of reach. Both heights already include the home bar. */
|
||||||
|
html[data-layout="classic"] .sidebar { padding-top: calc(24px + env(safe-area-inset-top)); padding-bottom: calc(24px + max(env(safe-area-inset-bottom), var(--classic-nav-height) + var(--classic-mini-height))); z-index: 350; }
|
||||||
html[data-layout="classic"] .sidebar-backdrop { z-index: 340; }
|
html[data-layout="classic"] .sidebar-backdrop { z-index: 340; }
|
||||||
html[data-layout="classic"] .player-pane { padding-inline: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right)); }
|
html[data-layout="classic"] .player-pane { padding-inline: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right)); }
|
||||||
html[data-layout="classic"] .player-placeholder { padding: 24px 8px 32px; }
|
html[data-layout="classic"] .player-placeholder { padding: 24px 8px 32px; }
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ function setListPaneHidden(hidden) {
|
|||||||
|
|
||||||
const LIST_ICONS = {
|
const LIST_ICONS = {
|
||||||
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
|
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
|
||||||
|
exportFile: '<path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M12 11v6m-3-3 3 3 3-3"/>',
|
||||||
|
server: '<rect x="4" y="4" width="16" height="6" rx="1.5"/><rect x="4" y="14" width="16" height="6" rx="1.5"/><path d="M8 7h.01M8 17h.01"/>',
|
||||||
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
|
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
|
||||||
offline: '<use href="#media-download"/>',
|
offline: '<use href="#media-download"/>',
|
||||||
offlineDone: '<use href="#media-offline"/>',
|
offlineDone: '<use href="#media-offline"/>',
|
||||||
@@ -312,6 +314,8 @@ function renderList() {
|
|||||||
els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } };
|
els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } };
|
||||||
const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))];
|
const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))];
|
||||||
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl)));
|
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl)));
|
||||||
|
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('exportFile', 'Export playlist file (titles and YouTube links)', () => exportPlaylists(pl.id)));
|
||||||
|
if (WEB && pl.videos && pl.videos.some((v) => /^[A-Za-z0-9_-]{11}$/.test(v.id))) titleIcons.push(listIconBtn('server', 'Download this playlist on the server', () => offerServerDownload(pl.videos.map((v) => v.id).filter((id) => /^[A-Za-z0-9_-]{11}$/.test(id)))));
|
||||||
$('listTitleActions').append(...titleIcons);
|
$('listTitleActions').append(...titleIcons);
|
||||||
|
|
||||||
// Filter row icons: offline + pin show their state by colour alone.
|
// Filter row icons: offline + pin show their state by colour alone.
|
||||||
@@ -401,8 +405,38 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
els.status.classList.add('hidden');
|
els.status.classList.add('hidden');
|
||||||
|
|
||||||
list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
|
// Long lists (a deep search returns up to 200) are drawn in chunks: the first
|
||||||
|
// screenful at once, the rest on later frames, so a phone never freezes.
|
||||||
|
const token = (renderList._token = (renderList._token || 0) + 1);
|
||||||
|
const FIRST = 30, STEP = 40;
|
||||||
|
const draw = (from, to) => {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let i = from; i < Math.min(to, list.length); i++) frag.appendChild(renderCard(list[i], i, list));
|
||||||
|
els.cards.appendChild(frag);
|
||||||
|
};
|
||||||
|
draw(0, FIRST);
|
||||||
markPlayingCard();
|
markPlayingCard();
|
||||||
|
let at = FIRST;
|
||||||
|
const more = () => {
|
||||||
|
if (renderList._token !== token || at >= list.length) return;
|
||||||
|
draw(at, at + STEP); at += STEP; markPlayingCard();
|
||||||
|
(window.requestIdleCallback || ((f) => setTimeout(f, 30)))(more);
|
||||||
|
};
|
||||||
|
if (list.length > FIRST) (window.requestIdleCallback || ((f) => setTimeout(f, 30)))(more);
|
||||||
|
}
|
||||||
|
// Search only: the server's fuller list arrived and starts with the cards
|
||||||
|
// already shown — add the new ones below instead of redrawing everything.
|
||||||
|
function appendListCards(list, fromIndex) {
|
||||||
|
const token = renderList._token;
|
||||||
|
let at = fromIndex;
|
||||||
|
const step = () => {
|
||||||
|
if (renderList._token !== token || at >= list.length) return;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const end = Math.min(at + 40, list.length); at < end; at++) frag.appendChild(renderCard(list[at], at, list));
|
||||||
|
els.cards.appendChild(frag);
|
||||||
|
(window.requestIdleCallback || ((f) => setTimeout(f, 30)))(step);
|
||||||
|
};
|
||||||
|
step();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBrowseCta(empty, label) {
|
function addBrowseCta(empty, label) {
|
||||||
@@ -1074,6 +1108,15 @@ async function renderSettings() {
|
|||||||
</span>
|
</span>
|
||||||
<span id="aboutBuildTime" class="about-version">…</span>
|
<span id="aboutBuildTime" class="about-version">…</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row hidden" id="aboutAdminRow">
|
||||||
|
<span>
|
||||||
|
Admin
|
||||||
|
<small>Lyrics studio, reports, library and server tools. Needs the admin password.</small>
|
||||||
|
</span>
|
||||||
|
<div class="set-actions" style="margin:0">
|
||||||
|
<a class="btn" href="/admin" id="aboutAdminLink" style="text-decoration:none;display:inline-flex;align-items:center">Open admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
c.appendChild(wrap);
|
c.appendChild(wrap);
|
||||||
@@ -1428,7 +1471,7 @@ async function renderSettings() {
|
|||||||
|
|
||||||
// ---- Export / Import playlists ----
|
// ---- Export / Import playlists ----
|
||||||
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
|
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
|
||||||
$('exportBtn').addEventListener('click', exportPlaylists);
|
$('exportBtn').addEventListener('click', () => exportPlaylists());
|
||||||
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
||||||
$('fileInput').addEventListener('change', importPlaylists);
|
$('fileInput').addEventListener('change', importPlaylists);
|
||||||
|
|
||||||
@@ -1456,6 +1499,34 @@ async function renderSettings() {
|
|||||||
});
|
});
|
||||||
updateProfileStatus();
|
updateProfileStatus();
|
||||||
|
|
||||||
|
// ---- About: Admin entry. Shown once this device has signed in as admin,
|
||||||
|
// or after a long-press / five taps on the version (then remembered). It
|
||||||
|
// opens /admin inside the app window, which has no address bar on iPhone.
|
||||||
|
if (WEB) {
|
||||||
|
const ADMIN_KEY = 'ytpAdminEntry';
|
||||||
|
const revealAdmin = (remember) => {
|
||||||
|
const row = $('aboutAdminRow');
|
||||||
|
if (row) row.classList.remove('hidden');
|
||||||
|
if (remember) { try { localStorage.setItem(ADMIN_KEY, '1'); } catch {} }
|
||||||
|
};
|
||||||
|
try { if (localStorage.getItem(ADMIN_KEY) === '1') revealAdmin(false); } catch {}
|
||||||
|
fetch('/api/admin/me', { cache: 'no-store', credentials: 'same-origin' })
|
||||||
|
.then((r) => (r.ok ? r.json() : null)).then((me) => { if (me && me.admin) revealAdmin(true); }).catch(() => {});
|
||||||
|
const ver = $('aboutVersion');
|
||||||
|
if (ver) {
|
||||||
|
let taps = 0, tapTimer = null, holdTimer = null;
|
||||||
|
const unlock = () => { if ($('aboutAdminRow')?.classList.contains('hidden')) { revealAdmin(true); toast('Admin entry shown in About'); } };
|
||||||
|
ver.addEventListener('pointerdown', () => { clearTimeout(holdTimer); holdTimer = setTimeout(unlock, 700); });
|
||||||
|
for (const n of ['pointerup', 'pointerleave', 'pointercancel']) ver.addEventListener(n, () => clearTimeout(holdTimer));
|
||||||
|
ver.addEventListener('click', () => {
|
||||||
|
taps++; clearTimeout(tapTimer); tapTimer = setTimeout(() => { taps = 0; }, 1500);
|
||||||
|
if (taps >= 5) { taps = 0; unlock(); }
|
||||||
|
});
|
||||||
|
ver.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
ver.style.webkitUserSelect = 'none'; ver.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- About: append server build tag + build time (WEB mode only) ----
|
// ---- About: append server build tag + build time (WEB mode only) ----
|
||||||
if (WEB) {
|
if (WEB) {
|
||||||
try {
|
try {
|
||||||
@@ -1527,6 +1598,8 @@ function renderCard(v, index, list) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
const isCached = cachedIds.has(v.id);
|
const isCached = cachedIds.has(v.id);
|
||||||
const isDownloading = downloading.has(v.id);
|
const isDownloading = downloading.has(v.id);
|
||||||
|
if (v.onServer) knownServerCachedIds.add(v.id);
|
||||||
|
const onServer = !isCached && (v.onServer || knownServerCachedIds.has(v.id));
|
||||||
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
|
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
|
||||||
card.dataset.id = v.id;
|
card.dataset.id = v.id;
|
||||||
card._video = v; // read by f7-layout.js swipe actions
|
card._video = v; // read by f7-layout.js swipe actions
|
||||||
@@ -1535,6 +1608,7 @@ function renderCard(v, index, list) {
|
|||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||||
|
${onServer ? '<span class="server-badge" title="A copy is on the server: starts fast and plays even when YouTube is slow"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="6" rx="1.5"/><rect x="4" y="14" width="16" height="6" rx="1.5"/><path d="M8 7h.01M8 17h.01"/></svg>On server</span>' : ''}
|
||||||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
|
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
|
||||||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,58 +2,85 @@
|
|||||||
(window.YT ||= {}).Party = (() => {
|
(window.YT ||= {}).Party = (() => {
|
||||||
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
||||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||||||
const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
|
const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName', KEY_KEY = 'ytpPartyKey';
|
||||||
const ls = {
|
const ls = {
|
||||||
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
||||||
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
||||||
};
|
};
|
||||||
let pid = ls.get(PID_KEY);
|
let pid = ls.get(PID_KEY);
|
||||||
if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
|
if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
|
||||||
|
// Private per-device key: the participant id is shown to everyone, this proves the seat is ours.
|
||||||
|
let seatKey = ls.get(KEY_KEY);
|
||||||
|
if (!seatKey) { const b = new Uint8Array(18); crypto.getRandomValues(b); seatKey = [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); ls.set(KEY_KEY, seatKey); }
|
||||||
|
let lastGestureAt = 0;
|
||||||
|
for (const n of ['pointerdown', 'keydown', 'touchstart']) addEventListener(n, () => { lastGestureAt = Date.now(); }, { capture: true, passive: true });
|
||||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
||||||
|
|
||||||
const st = {
|
const st = {
|
||||||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||||
dj: { enabled: false, entries: [] }, djPending: false,
|
dj: { enabled: false, entries: [] }, djPending: false,
|
||||||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||||||
|
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '', pq: [], viewMode: {}, reader: { url: '', html: '', error: '', loading: false },
|
||||||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||||
minimized: false,
|
minimized: false, tab: 'chat', retries: 0, lastSeekAt: 0, pingAt: 0, lastPong: 0,
|
||||||
|
gentle: ls.get('ytpPartyGentle') !== 'false', session: 0, replaced: false,
|
||||||
};
|
};
|
||||||
const inParty = () => !!st.role;
|
const inParty = () => !!st.role;
|
||||||
const isHost = () => st.role === 'host';
|
const isHost = () => st.role === 'host';
|
||||||
const guestActive = () => st.role === 'guest' && !st.detached;
|
const guestActive = () => st.role === 'guest' && !st.detached;
|
||||||
const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
|
const send = (m) => {
|
||||||
|
if (navigator.onLine === false || !st.ws || st.ws.readyState !== 1 || st.ws.bufferedAmount > 65536) return false;
|
||||||
|
try { st.ws.send(JSON.stringify(m)); return true; } catch { return false; }
|
||||||
|
};
|
||||||
const serverNow = () => Date.now() + st.offset;
|
const serverNow = () => Date.now() + st.offset;
|
||||||
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
|
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
|
||||||
|
|
||||||
// ---------- connection ----------
|
// ---------- connection ----------
|
||||||
function connect() {
|
function connect() {
|
||||||
clearTimeout(st.retry);
|
clearTimeout(st.retry);
|
||||||
const q = { role: st.role, pid, name: myName() || 'Guest' };
|
if (navigator.onLine === false) { setStatus('Offline · waiting for your connection'); return; }
|
||||||
|
const old = st.ws; st.ws = null;
|
||||||
|
try { old?.close(); } catch { /* gone */ }
|
||||||
|
const q = { role: st.role, pid, key: seatKey, name: myName() || 'Guest' };
|
||||||
if (st.code) q.code = st.code;
|
if (st.code) q.code = st.code;
|
||||||
if (isHost() && st.secret) q.secret = st.secret;
|
if (isHost() && st.secret) q.secret = st.secret;
|
||||||
const ws = new WebSocket(wsUrl(q));
|
const ws = new WebSocket(wsUrl(q));
|
||||||
st.ws = ws;
|
st.ws = ws; st.lastPong = Date.now();
|
||||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
|
ws.onmessage = (e) => { if (st.ws !== ws) return; let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
if (st.ws !== ws) return;
|
if (st.ws !== ws) return;
|
||||||
st.ws = null;
|
st.ws = null;
|
||||||
if (!inParty()) return;
|
if (!inParty()) return;
|
||||||
if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
|
if (e.code === 4000) { setStatus('Opened on another tab or device — tap Re-sync to use this one'); st.detached = true; st.replaced = true; render(); return; }
|
||||||
const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
|
if (e.code === 4010 || e.code === 4004 || e.code === 4003 || e.code === 4005) {
|
||||||
|
const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.'
|
||||||
|
: e.code === 4005 ? 'Someone else is using this seat in the party.' : 'The party ended.';
|
||||||
leave(false);
|
leave(false);
|
||||||
toast(`🎉 ${why}`, { duration: 5000 });
|
toast(`🎉 ${why}`, { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus('Reconnecting…');
|
const delay = Math.min(30000, 1000 * 2 ** Math.min(st.retries++, 5)) + Math.random() * 500;
|
||||||
st.retry = setTimeout(connect, 3000);
|
setStatus(`Connection lost · retrying in ${Math.ceil(delay / 1000)}s`);
|
||||||
|
st.retry = setTimeout(connect, delay);
|
||||||
};
|
};
|
||||||
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
|
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMessage(m) {
|
function onMessage(m) {
|
||||||
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
|
||||||
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
||||||
|
if (m.type === 'pong') {
|
||||||
|
if (m.echo !== st.pingAt) return;
|
||||||
|
const rtt = Date.now() - st.pingAt;
|
||||||
|
st.lastPong = Date.now();
|
||||||
|
if (rtt < 5000) st.offset = m.now - (st.pingAt + rtt / 2);
|
||||||
|
const el = $('ptLatency'); if (el) el.textContent = `${Math.round(rtt)} ms round trip`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m.type === 'hello') {
|
if (m.type === 'hello') {
|
||||||
|
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
||||||
|
|
||||||
|
st.retries = 0; st.lastPong = Date.now(); st.lastPush = '';
|
||||||
|
st.state = m.state || null;
|
||||||
st.code = m.code;
|
st.code = m.code;
|
||||||
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
||||||
st.offset = (m.now || Date.now()) - Date.now();
|
st.offset = (m.now || Date.now()) - Date.now();
|
||||||
@@ -61,10 +88,32 @@
|
|||||||
st.chat = m.chat || [];
|
st.chat = m.chat || [];
|
||||||
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||||
st.allowControl = !!m.allowControl;
|
st.allowControl = !!m.allowControl;
|
||||||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
st.present = m.present || null; renderPresent();
|
||||||
|
st.invites = m.invites || []; st.pq = m.pq || [];
|
||||||
|
setStatus(isHost() ? 'You’re hosting' : st.detached ? 'Watching independently' : 'Following the host');
|
||||||
|
if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
|
||||||
render();
|
render();
|
||||||
if (isHost()) pushState(true);
|
if (isHost()) {
|
||||||
else if (m.state) applyState(m.state);
|
// A host reload: put the party's song back before broadcasting, or
|
||||||
|
// everyone would be sent an empty player.
|
||||||
|
const saved = m.state && m.state.v;
|
||||||
|
if (saved && (!current || !current.meta || current.meta.id !== saved.id)) {
|
||||||
|
st.restoring = true;
|
||||||
|
const want = targetTime(m.state);
|
||||||
|
// playVideoAt seeks only once the media can play; broadcast after that
|
||||||
|
// seek lands, or guests are briefly sent back to 0:00.
|
||||||
|
const landed = () => new Promise((resolve) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const check = () => { const M = Player.master; if (!want || (M && Math.abs((M.currentTime || 0) - want) < 2) || Date.now() - t0 > 8000) resolve(); else setTimeout(check, 150); };
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
Promise.resolve(playVideoAt({ ...saved }, want)).catch(() => {}).then(landed).finally(() => {
|
||||||
|
st.restoring = false;
|
||||||
|
if (m.state.paused) Player.pause();
|
||||||
|
pushState(true);
|
||||||
|
});
|
||||||
|
} else pushState(true);
|
||||||
|
} else if (m.state) applyState(m.state);
|
||||||
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
||||||
} else if (m.type === 'dj') {
|
} else if (m.type === 'dj') {
|
||||||
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
||||||
@@ -75,6 +124,7 @@
|
|||||||
if (st.role === 'guest') applyState(m.state);
|
if (st.role === 'guest') applyState(m.state);
|
||||||
} else if (m.type === 'members') {
|
} else if (m.type === 'members') {
|
||||||
st.members = m.members || [];
|
st.members = m.members || [];
|
||||||
|
if (m.hostAway) setStatus('Host disconnected · waiting for them to return');
|
||||||
if (m.joined) sysMsg(`${m.joined} joined`);
|
if (m.joined) sysMsg(`${m.joined} joined`);
|
||||||
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
|
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
|
||||||
render();
|
render();
|
||||||
@@ -88,6 +138,18 @@
|
|||||||
st.allowControl = !!m.allowControl;
|
st.allowControl = !!m.allowControl;
|
||||||
render();
|
render();
|
||||||
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
||||||
|
} else if (m.type === 'pq') {
|
||||||
|
st.pq = m.items || []; render();
|
||||||
|
} else if (m.type === 'invites') {
|
||||||
|
st.invites = m.invites || []; st.inviteError = m.error || '';
|
||||||
|
if (m.error) toast('⚠ ' + m.error);
|
||||||
|
render();
|
||||||
|
} else if (m.type === 'present') {
|
||||||
|
const was = st.present && st.present.url;
|
||||||
|
st.present = m.present || null;
|
||||||
|
if (!st.present || st.present.url !== was) st.presentHidden = false;
|
||||||
|
if (st.present && st.present.url !== was && st.present.by !== myName()) toast(`🖥 ${st.present.by} is presenting`);
|
||||||
|
renderPresent(); render();
|
||||||
} else if (m.type === 'cmd' && isHost()) {
|
} else if (m.type === 'cmd' && isHost()) {
|
||||||
runCommand(m);
|
runCommand(m);
|
||||||
} else if (m.type === 'rtc') {
|
} else if (m.type === 'rtc') {
|
||||||
@@ -110,12 +172,12 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
function pushState(force = false) {
|
function pushState(force = false) {
|
||||||
if (!isHost() || !st.ws) return;
|
if (!isHost() || st.ws?.readyState !== 1 || st.restoring) return;
|
||||||
const s = stateNow();
|
const s = stateNow();
|
||||||
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
||||||
if (!force && key === st.lastPush) return;
|
if (!force && key === st.lastPush) return;
|
||||||
st.lastPush = key;
|
if (send({ type: 'state', state: s })) st.lastPush = key;
|
||||||
send({ type: 'state', state: s });
|
updateNowPlaying(s.v);
|
||||||
}
|
}
|
||||||
function runCommand(m) {
|
function runCommand(m) {
|
||||||
switch (m.cmd) {
|
switch (m.cmd) {
|
||||||
@@ -135,27 +197,46 @@
|
|||||||
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
|
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
|
||||||
function targetTime(s) {
|
function targetTime(s) {
|
||||||
if (!s) return 0;
|
if (!s) return 0;
|
||||||
return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
|
return Math.max(0, s.paused ? s.cur : s.cur + Math.max(0, (serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1));
|
||||||
}
|
}
|
||||||
async function applyState(s) {
|
async function applyState(s) {
|
||||||
st.state = s;
|
st.state = s;
|
||||||
|
updateNowPlaying(s?.v);
|
||||||
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
||||||
|
if (!st.members.some(m => m.host)) return;
|
||||||
|
if (st.loadingId) return; // a load is running; its finish re-applies the newest state
|
||||||
const target = targetTime(s);
|
const target = targetTime(s);
|
||||||
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
||||||
quiet(6000);
|
const session = st.session;
|
||||||
await playVideoAt({ ...s.v }, target);
|
st.loadingId = s.v.id;
|
||||||
|
quiet(30000);
|
||||||
|
try { await playVideoAt({ ...s.v }, target); } catch { /* the drift timer retries */ }
|
||||||
|
finally { if (session === st.session) { st.loadingId = ''; quiet(); } }
|
||||||
|
if (session !== st.session) return;
|
||||||
|
if (st.state && st.state.v && current && current.meta && current.meta.id === st.state.v.id) applyState(st.state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const M = Player.master;
|
const M = Player.master;
|
||||||
if (!M) return;
|
if (!M) return;
|
||||||
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
|
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
|
||||||
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
|
// Do not keep discarding buffered data while a slow device/network catches up.
|
||||||
|
const drift = Math.abs((M.currentTime || 0) - target);
|
||||||
|
const threshold = st.gentle && !s.paused ? 4 : 1.5;
|
||||||
|
if (drift > threshold && !M.seeking && (s.paused || M.readyState >= 3) &&
|
||||||
|
Date.now() - st.lastSeekAt > (st.gentle ? 8000 : 2500)) {
|
||||||
|
st.lastSeekAt = Date.now(); quiet(); Player.seek(Math.min(target, Number.isFinite(M.duration) ? M.duration : target));
|
||||||
|
}
|
||||||
|
if (!st.ws || st.ws.readyState !== 1) return;
|
||||||
|
setStatus(M.readyState < 3 && !s.paused ? 'Buffering · keeping your place' : 'Following the host');
|
||||||
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
|
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
|
||||||
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
|
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
|
||||||
}
|
}
|
||||||
// A guest's own actions: forwarded when allowed, else they go off sync.
|
// A guest's own actions: forwarded when allowed, else they go off sync.
|
||||||
function onLocalAction(kind) {
|
function onLocalAction(kind) {
|
||||||
if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
|
if (st.role !== 'guest' || st.detached || st.loadingId || Date.now() < st.quietUntil) return;
|
||||||
|
// Buffering, drift correction and media reattachment fire play/pause/seeked
|
||||||
|
// too; only a real tap or key press is the guest's own decision.
|
||||||
|
if (Date.now() - lastGestureAt > 1500) return;
|
||||||
if (st.allowControl) {
|
if (st.allowControl) {
|
||||||
const M = Player.master;
|
const M = Player.master;
|
||||||
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
||||||
@@ -174,7 +255,8 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function resync() {
|
function resync() {
|
||||||
st.detached = false;
|
st.detached = false; st.replaced = false; st.lastSeekAt = 0;
|
||||||
|
if (!st.ws || st.ws.readyState !== 1) { setStatus('Reconnecting…'); connect(); render(); return; }
|
||||||
setStatus('Synced with the host');
|
setStatus('Synced with the host');
|
||||||
render();
|
render();
|
||||||
applyState(st.state);
|
applyState(st.state);
|
||||||
@@ -269,6 +351,8 @@
|
|||||||
|
|
||||||
// ---------- lifecycle ----------
|
// ---------- lifecycle ----------
|
||||||
function start() {
|
function start() {
|
||||||
|
st.tab = 'chat';
|
||||||
|
st.session++; st.replaced = false; st.detached = false; st.loadingId = ''; st.state = null;
|
||||||
st.role = 'host';
|
st.role = 'host';
|
||||||
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
|
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
|
||||||
ls.set(HOST_KEY, null);
|
ls.set(HOST_KEY, null);
|
||||||
@@ -280,8 +364,11 @@
|
|||||||
function join(code, name) {
|
function join(code, name) {
|
||||||
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
||||||
|
if (inParty()) leave(false);
|
||||||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||||
st.role = 'guest';
|
st.session++; st.replaced = false; st.loadingId = ''; st.state = null; st.lastSeekAt = 0;
|
||||||
|
st.tab = 'chat';
|
||||||
|
st.role = 'guest'; st.inviteCode = '';
|
||||||
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
||||||
showPanel();
|
showPanel();
|
||||||
setStatus('Joining…');
|
setStatus('Joining…');
|
||||||
@@ -290,19 +377,28 @@
|
|||||||
}
|
}
|
||||||
function beginTimers() {
|
function beginTimers() {
|
||||||
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||||
st.pushTimer = setInterval(() => pushState(), 3000);
|
st.pushTimer = setInterval(() => {
|
||||||
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
|
pushState();
|
||||||
|
if (st.ws?.readyState === 1 && Date.now() - st.pingAt >= 15000) {
|
||||||
|
if (st.lastPong && Date.now() - st.lastPong > 45000) { setStatus('Reconnecting…'); connect(); return; }
|
||||||
|
st.pingAt = Date.now(); send({ type: 'ping', echo: st.pingAt });
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.ws?.readyState === 1 && st.state && !st.detached) applyState(st.state); }, 2500);
|
||||||
}
|
}
|
||||||
function leave(sendEnd = true) {
|
function leave(sendEnd = true) {
|
||||||
if (isHost() && sendEnd) send({ type: 'end' });
|
if (isHost() && sendEnd) send({ type: 'end' });
|
||||||
if (st.voice.on) toggleVoice();
|
if (st.voice.on) toggleVoice();
|
||||||
const ws = st.ws;
|
const ws = st.ws;
|
||||||
st.ws = null;
|
st.ws = null;
|
||||||
|
st.session++; st.loadingId = ''; st.state = null; st.retries = 0;
|
||||||
st.role = null; st.code = ''; st.secret = '';
|
st.role = null; st.code = ''; st.secret = '';
|
||||||
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||||
ls.set(HOST_KEY, null);
|
ls.set(HOST_KEY, null);
|
||||||
|
st.present = null; renderPresent();
|
||||||
$('partyPanel').classList.add('hidden');
|
$('partyPanel').classList.add('hidden');
|
||||||
|
$('partyPanel').querySelector('.pt-body').dataset.view = '';
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,70 +410,147 @@
|
|||||||
function renderLyricsToggle() {
|
function renderLyricsToggle() {
|
||||||
const b = document.querySelector('#partyPanel .pt-lyrics');
|
const b = document.querySelector('#partyPanel .pt-lyrics');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
|
b.textContent = lyricsOn() ? 'Show video' : 'Lyrics instead of video';
|
||||||
b.classList.toggle('on', lyricsOn());
|
b.classList.toggle('on', lyricsOn());
|
||||||
|
const l = document.querySelector('#partyPanel .pt-land');
|
||||||
|
if (l) { l.hidden = !lyricsOn(); l.textContent = Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'; }
|
||||||
}
|
}
|
||||||
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
function setStatus(t) { const el = $('ptStatus'); if (el && el.textContent !== t) el.textContent = t; }
|
||||||
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); if (st.chat.length > 100) st.chat.shift(); renderChat(); }
|
||||||
function link() { return `${location.origin}/?party=${st.code}`; }
|
function link() { return `${location.origin}/?party=${st.code}`; }
|
||||||
function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
function showPanel() { Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||||||
|
|
||||||
|
function updateNowPlaying(video) {
|
||||||
|
const el = $('ptNow');
|
||||||
|
const title = video?.title || 'Choose a video to watch together';
|
||||||
|
if (el && el.textContent !== title) el.textContent = title;
|
||||||
|
const play = document.querySelector('#partyPanel [data-act="playback"]');
|
||||||
|
if (play) { const label = Player.master?.paused ? 'Play' : 'Pause'; if (play.textContent !== label) play.textContent = label; play.disabled = !current?.meta; }
|
||||||
|
}
|
||||||
|
function updateSection(selector, key, html) {
|
||||||
|
const el = document.querySelector(`#partyPanel ${selector}`);
|
||||||
|
if (!el || el._key === key) return;
|
||||||
|
el._key = key; el.innerHTML = html;
|
||||||
|
}
|
||||||
|
function selectTab(tab) {
|
||||||
|
st.tab = tab;
|
||||||
|
const panel = $('partyPanel');
|
||||||
|
panel.querySelectorAll('[data-tab]').forEach(b => {
|
||||||
|
b.setAttribute('aria-selected', String(b.dataset.tab === tab));
|
||||||
|
b.tabIndex = b.dataset.tab === tab ? 0 : -1;
|
||||||
|
});
|
||||||
|
panel.querySelectorAll('[data-pane]').forEach(p => { p.hidden = p.dataset.pane !== tab; });
|
||||||
|
if (tab === 'chat') renderChat();
|
||||||
|
if (tab === 'queue') Lazy.load('feature:party-dj').then(render).catch(error => toast(error.message));
|
||||||
|
}
|
||||||
function render() {
|
function render() {
|
||||||
const panel = $('partyPanel');
|
const panel = $('partyPanel');
|
||||||
const chip = $('navPartyBtn');
|
const label = $('navPartyBtn')?.querySelector('.nav-label');
|
||||||
if (chip) {
|
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
|
||||||
const label = chip.querySelector('.nav-label');
|
if (!panel || panel.classList.contains('hidden') || st.minimized) return;
|
||||||
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
|
const body = panel.querySelector('.pt-body');
|
||||||
else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
|
|
||||||
}
|
|
||||||
if (!panel || panel.classList.contains('hidden')) return;
|
|
||||||
const nameOk = !!myName();
|
|
||||||
if (!inParty()) {
|
if (!inParty()) {
|
||||||
panel.querySelector('.pt-body').innerHTML = `
|
$('ptTitle').textContent = 'Watch party';
|
||||||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
if (body.dataset.view === 'lobby') return;
|
||||||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
body.dataset.view = 'lobby';
|
||||||
|
body.innerHTML = `<div class="pt-lobby"><h2>Good company.<br>One shared screen.</h2>
|
||||||
|
<p>Watch in sync, talk in chat, and choose what plays next.</p>
|
||||||
|
${myName() ? '' : '<label class="pt-field">Your name<input class="pt-name" maxlength="40" autocomplete="nickname" placeholder="Your name" /></label>'}
|
||||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||||
<div class="pt-or">or join one</div>
|
<div class="pt-or">Have an invitation?</div>
|
||||||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
<label class="pt-field" for="ptCode">Party code</label><div class="pt-row"><input id="ptCode" class="pt-code" maxlength="6" placeholder="ABC123" autocapitalize="characters" autocomplete="off" value="${escapeHtml(st.inviteCode)}" /><button class="np-btn" type="button" data-act="join">Join</button></div>
|
||||||
$('ptTitle').textContent = '🎉 Watch party';
|
<p class="pt-hint">Each person streams on their own device. Audio-only is available for slower connections.</p></div>`;
|
||||||
setStatus('');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
|
$('ptTitle').textContent = isHost() ? 'Your watch party' : 'Watch party';
|
||||||
const s = st.state && st.state.v;
|
if (body.dataset.view !== 'room') {
|
||||||
panel.querySelector('.pt-body').innerHTML = `
|
body.dataset.view = 'room';
|
||||||
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
body.innerHTML = `<div class="pt-session"><div><span class="pt-caption">Invitation code</span><strong id="ptRoomCode"></strong></div><button class="np-btn" data-act="copy" type="button">Copy link</button>${navigator.share ? '<button class="np-btn" data-act="share" type="button">Share</button>' : ''}</div>
|
||||||
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
|
<div class="pt-playing"><span class="pt-caption">Watching together</span><strong id="ptNow"></strong><button class="pt-resync np-btn" type="button" data-act="resync">Re-sync</button></div>
|
||||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
${isHost() ? `<div class="pt-transport" aria-label="Host playback controls"><button class="np-btn" type="button" data-act="back10" aria-label="Back 10 seconds">−10 s</button><button class="np-btn" type="button" data-act="playback">Play</button><button class="np-btn" type="button" data-act="forward10" aria-label="Forward 10 seconds">+10 s</button><button class="np-btn" type="button" data-act="next">Next</button></div>` : ''}
|
||||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
<div class="pt-tabs" role="tablist" aria-label="Party sections">${['chat','queue','room'].map(t => `<button type="button" id="ptTab-${t}" role="tab" data-tab="${t}" aria-controls="ptPane-${t}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}</div>
|
||||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
<section id="ptPane-chat" class="pt-pane" data-pane="chat" role="tabpanel" aria-labelledby="ptTab-chat">
|
||||||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
<div class="pt-chat" id="ptChat" role="log" aria-label="Party chat" aria-live="polite" aria-relevant="additions" tabindex="0"></div>
|
||||||
<div class="pt-voice">
|
<button type="button" class="pt-new np-btn" data-act="latest" hidden>New messages · jump to latest</button>
|
||||||
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
<form class="pt-send"><input class="pt-msg" aria-label="Message the party" maxlength="500" placeholder="Say something…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||||||
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
</section>
|
||||||
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
<section id="ptPane-queue" class="pt-pane" data-pane="queue" role="tabpanel" aria-labelledby="ptTab-queue" hidden><p class="pt-hint">Suggest a video and vote for what’s next. The host approves the queue.</p><div class="pt-dj"></div></section>
|
||||||
</div>
|
<section id="ptPane-room" class="pt-pane" data-pane="room" role="tabpanel" aria-labelledby="ptTab-room" hidden>
|
||||||
<div class="pt-chat" id="ptChat"></div>
|
<h3>In the room <span id="ptMemberCount"></span></h3><div class="pt-members"></div>
|
||||||
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
<div class="pt-options"><h3>On this device</h3>
|
||||||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
<label class="pt-toggle"><input type="checkbox" data-act="gentle" />Gentle sync</label><p class="pt-hint">Allows a small delay and fewer seeks when your connection struggles.</p>
|
||||||
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
<label class="pt-toggle"><input type="checkbox" data-act="audio" />Audio only</label><p class="pt-hint">Use the audio stream to reduce video data and decoding.</p><span id="ptLatency" class="pt-hint">Measuring connection…</span>
|
||||||
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
</div><div class="pt-voice"></div><div class="pt-present"></div><div class="pt-host"></div>
|
||||||
renderChat();
|
${isHost() ? `<div class="pt-pq"></div>
|
||||||
|
<div class="pt-invite-box"><h3>Invite profiles</h3><form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Profile name" aria-label="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form><div class="pt-members pt-invited"></div></div>` : ''}
|
||||||
|
<div class="pt-lyrics-controls"><button class="np-btn pt-lyrics" type="button" data-act="lyrics">Lyrics instead of video</button><button class="np-btn pt-land" type="button" data-act="land" hidden>Landscape lyrics</button></div>
|
||||||
|
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>
|
||||||
|
</section>`;
|
||||||
|
selectTab(st.tab);
|
||||||
|
}
|
||||||
|
$('ptRoomCode').textContent = st.code || 'Connecting…';
|
||||||
|
panel.querySelector('[data-act="copy"]').disabled = !st.code;
|
||||||
|
panel.querySelector('.pt-resync').hidden = isHost();
|
||||||
|
updateNowPlaying(isHost() ? current?.meta : st.state?.v);
|
||||||
|
$('ptMemberCount').textContent = `(${st.members.length})`;
|
||||||
|
updateSection('.pt-members', JSON.stringify(st.members), st.members.map(m => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${escapeHtml(m.name)}${m.host ? ' · Host' : ''}${m.voice ? ' · Voice' : ''}</span>`).join(''));
|
||||||
|
panel.querySelector('[data-act="gentle"]').checked = st.gentle;
|
||||||
|
panel.querySelector('[data-act="audio"]').checked = !!data.settings.audioOnly;
|
||||||
|
updateSection('.pt-voice', `${st.voice.on}/${st.voice.muted}/${st.voice.pcs.size}`, `<button class="np-btn" type="button" data-act="voice">${st.voice.on ? 'Leave voice' : 'Join voice'}</button>${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? 'Unmute' : 'Mute'}</button><span class="pt-voice-n">${st.voice.pcs.size} connected</span>` : '<span class="pt-hint">Microphone is off</span>'}`);
|
||||||
|
updateSection('.pt-present', `${!!st.present}/${st.presentHidden}/${canPresent()}`, `${st.present && st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">Show presentation</button>' : ''}${canPresent() ? `<button class="np-btn" type="button" data-act="present">${st.present ? 'Change presentation' : 'Present a link'}</button>${st.present ? '<button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}` : ''}`);
|
||||||
|
const host = panel.querySelector('.pt-host');
|
||||||
|
if (isHost() && !host.firstChild) host.innerHTML = `<h3>Host controls</h3><label class="pt-toggle"><input type="checkbox" data-act="allow" />Guests can control playback</label>`;
|
||||||
|
if (isHost()) {
|
||||||
|
host.querySelector('[data-act="allow"]').checked = st.allowControl;
|
||||||
|
}
|
||||||
|
if (isHost()) {
|
||||||
|
updateSection('.pt-pq', JSON.stringify([st.pq, st.present && st.present.url]), `<div class="pt-pq-head"><b>Prepared</b><button class="np-btn" type="button" data-act="pq-new">+ Prepare</button></div>
|
||||||
|
${st.pq.length ? st.pq.map((q, i) => `<div class="pt-pq-row${st.present && st.present.url === q.url ? ' live' : ''}">
|
||||||
|
<span class="pt-pq-name">${escapeHtml(q.title || embedOf(q.url, 1).label || q.url)}<small>${q.mode === 'reader' ? 'Reader' : escapeHtml(embedOf(q.url, 1).label || '')}</small></span>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-go" data-id="${escapeHtml(q.id)}">${st.present && st.present.url === q.url ? 'Showing' : 'Present'}</button>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-up" data-id="${escapeHtml(q.id)}" aria-label="Move up"${i ? '' : ' disabled'}>↑</button>
|
||||||
|
<button class="np-btn" type="button" data-act="pq-del" data-id="${escapeHtml(q.id)}" aria-label="Remove">×</button></div>`).join('')
|
||||||
|
: '<p class="pt-pq-empty">Line up slides, documents and pages now; present each one when its time comes.</p>'}`);
|
||||||
|
updateSection('.pt-invited', JSON.stringify(st.invites), st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join(''));
|
||||||
|
}
|
||||||
|
const dj = panel.querySelector('.pt-dj');
|
||||||
|
const djKey = JSON.stringify(st.dj);
|
||||||
|
if (Lazy.loaded('feature:party-dj') && dj._key !== djKey) {
|
||||||
|
dj._key = djKey;
|
||||||
|
PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||||
|
if (!st.dj.enabled) dj.insertAdjacentHTML('beforeend', `<p class="pt-hint">${isHost() ? 'Enable Party DJ to let everyone suggest and vote.' : 'The host hasn’t enabled suggestions yet.'}</p>`);
|
||||||
|
}
|
||||||
|
renderLyricsToggle(); renderChat();
|
||||||
}
|
}
|
||||||
function renderChat() {
|
function renderChat() {
|
||||||
const box = $('ptChat');
|
const box = $('ptChat');
|
||||||
if (!box) return;
|
if (!box || st.minimized || st.tab !== 'chat' || $('partyPanel').classList.contains('hidden')) return;
|
||||||
box.innerHTML = st.chat.map((c) => c.sys
|
const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 48;
|
||||||
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
const previous = box._messages || [];
|
||||||
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
if (box._messages && previous.length === st.chat.length && previous.every((c, i) => c === st.chat[i])) return;
|
||||||
box.scrollTop = box.scrollHeight;
|
const appendOnly = previous.length <= st.chat.length && previous.every((c, i) => c === st.chat[i]);
|
||||||
|
const scroll = box.scrollTop;
|
||||||
|
if (!appendOnly || !previous.length) box.replaceChildren();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const c of st.chat.slice(appendOnly ? previous.length : 0)) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = c.sys ? 'pt-sys' : `pt-line${c.pid === pid ? ' me' : ''}`;
|
||||||
|
if (c.sys) row.textContent = c.text;
|
||||||
|
else { const name = document.createElement('b'); name.textContent = c.from; const message = document.createElement('span'); message.textContent = c.text; row.append(name, message); }
|
||||||
|
fragment.append(row);
|
||||||
|
}
|
||||||
|
box.append(fragment); box._messages = st.chat.slice();
|
||||||
|
if (!st.chat.length) box.innerHTML = '<p class="pt-empty">You’re in good company.<br>Start the conversation.</p>';
|
||||||
|
const latest = document.querySelector('#partyPanel .pt-new');
|
||||||
|
if (nearBottom || !previous.length) { box.scrollTop = box.scrollHeight; latest.hidden = true; }
|
||||||
|
else { box.scrollTop = scroll; latest.hidden = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
const panel = $('partyPanel');
|
const panel = $('partyPanel');
|
||||||
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
||||||
if (panel.classList.contains('hidden')) showPanel();
|
if (panel.classList.contains('hidden')) showPanel();
|
||||||
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
|
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); render(); }
|
||||||
else if (!inParty()) panel.classList.add('hidden');
|
else if (!inParty()) panel.classList.add('hidden');
|
||||||
else { st.minimized = true; panel.classList.add('min'); }
|
else { st.minimized = true; panel.classList.add('min'); }
|
||||||
}
|
}
|
||||||
@@ -387,10 +560,19 @@
|
|||||||
$('ptMin').addEventListener('click', () => {
|
$('ptMin').addEventListener('click', () => {
|
||||||
if (!inParty()) { panel.classList.add('hidden'); return; }
|
if (!inParty()) { panel.classList.add('hidden'); return; }
|
||||||
st.minimized = !st.minimized;
|
st.minimized = !st.minimized;
|
||||||
panel.classList.toggle('min', st.minimized);
|
panel.classList.toggle('min', st.minimized); if (!st.minimized) render();
|
||||||
|
});
|
||||||
|
panel.addEventListener('keydown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.target.matches('[data-tab]') && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
|
||||||
|
e.preventDefault(); const tabs = ['chat', 'queue', 'room'];
|
||||||
|
const i = e.key === 'Home' ? 0 : e.key === 'End' ? 2 : (tabs.indexOf(st.tab) + (e.key === 'ArrowRight' ? 1 : 2)) % 3;
|
||||||
|
selectTab(tabs[i]); panel.querySelector(`[data-tab="${tabs[i]}"]`).focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && e.target.matches('.pt-code')) { e.preventDefault(); panel.querySelector('[data-act="join"]').click(); }
|
||||||
});
|
});
|
||||||
panel.addEventListener('keydown', (e) => e.stopPropagation());
|
|
||||||
panel.addEventListener('click', async (e) => {
|
panel.addEventListener('click', async (e) => {
|
||||||
|
const tab = e.target.closest('[data-tab]'); if (tab) { selectTab(tab.dataset.tab); return; }
|
||||||
const a = e.target.closest('[data-act]');
|
const a = e.target.closest('[data-act]');
|
||||||
if (!a || a.tagName === 'INPUT') return;
|
if (!a || a.tagName === 'INPUT') return;
|
||||||
const act = a.dataset.act;
|
const act = a.dataset.act;
|
||||||
@@ -398,24 +580,56 @@
|
|||||||
const name = nameIn ? nameIn.value.trim() : '';
|
const name = nameIn ? nameIn.value.trim() : '';
|
||||||
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
|
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
|
||||||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||||
if (act === 'start') start();
|
if (act === 'latest') { const box = $('ptChat'); box.scrollTop = box.scrollHeight; a.hidden = true; }
|
||||||
|
else if (isHost() && ['back10', 'forward10', 'playback', 'next'].includes(act)) {
|
||||||
|
if (act === 'playback') Player.toggle();
|
||||||
|
else if (act === 'next') playNext();
|
||||||
|
else Player.seek(Math.max(0, (Player.master?.currentTime || 0) + (act === 'back10' ? -10 : 10)));
|
||||||
|
pushState(true);
|
||||||
|
}
|
||||||
|
else if (act === 'start') start();
|
||||||
else if (act === 'join') join(panel.querySelector('.pt-code').value);
|
else if (act === 'join') join(panel.querySelector('.pt-code').value);
|
||||||
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
|
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
|
||||||
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
|
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
|
||||||
else if (act === 'resync') resync();
|
else if (act === 'resync') resync();
|
||||||
else if (act === 'voice') toggleVoice();
|
else if (act === 'voice') toggleVoice();
|
||||||
else if (act === 'mute') toggleMute();
|
else if (act === 'mute') toggleMute();
|
||||||
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
|
else if (act === 'lyrics') {
|
||||||
|
const on = !lyricsOn();
|
||||||
|
Notes.stageLyrics.set(on);
|
||||||
|
// Phones held upright go straight to landscape lyrics for the sing-along.
|
||||||
|
if (on && matchMedia('(pointer: coarse) and (orientation: portrait)').matches) Notes.stageLyrics.landscape?.(true);
|
||||||
|
renderLyricsToggle();
|
||||||
|
}
|
||||||
|
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
|
||||||
else if (act === 'leave') leave(true);
|
else if (act === 'leave') leave(true);
|
||||||
|
else if (act === 'present') presentPrompt();
|
||||||
|
else if (act === 'pq-new') presentPrompt({ queue: true });
|
||||||
|
else if (act === 'pq-go') { const q = st.pq.find((x) => x.id === a.dataset.id); if (q) send({ type: 'present', present: { url: q.url, mode: q.mode, page: 1 } }); }
|
||||||
|
else if (act === 'pq-up') send({ type: 'pq', action: 'move', id: a.dataset.id, by: -1 });
|
||||||
|
else if (act === 'pq-del') send({ type: 'pq', action: 'remove', id: a.dataset.id });
|
||||||
|
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
|
||||||
|
else if (act === 'present-stop') send({ type: 'present', present: null });
|
||||||
|
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
|
||||||
});
|
});
|
||||||
panel.addEventListener('change', (e) => {
|
panel.addEventListener('change', (e) => {
|
||||||
|
if (e.target.dataset.act === 'gentle') { st.gentle = e.target.checked; ls.set('ytpPartyGentle', String(st.gentle)); }
|
||||||
|
if (e.target.dataset.act === 'audio') {
|
||||||
|
quiet(10000); els.audioOnlyToggle.checked = e.target.checked;
|
||||||
|
els.audioOnlyToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
|
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
|
||||||
});
|
});
|
||||||
panel.addEventListener('submit', (e) => {
|
panel.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (e.target.closest('.pt-invite')) {
|
||||||
|
const inp = panel.querySelector('.pt-invite-name'), name = inp.value.trim().toLowerCase();
|
||||||
|
if (name) { send({ type: 'invite', profile: name }); inp.value = ''; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const input = panel.querySelector('.pt-msg');
|
const input = panel.querySelector('.pt-msg');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
if (text) { if (send({ type: 'chat', text })) input.value = ''; else toast('Not connected yet. Your message is still here.'); }
|
||||||
});
|
});
|
||||||
// Host: push on every player change; guests: detect their own actions.
|
// Host: push on every player change; guests: detect their own actions.
|
||||||
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
|
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
|
||||||
@@ -427,9 +641,185 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- invites: parties that invited this device's online profile ----------
|
||||||
|
const SEEN_KEY = 'ytpPartyInvitesSeen';
|
||||||
|
let invitesOpen = false;
|
||||||
|
async function checkInvites({ force = false } = {}) {
|
||||||
|
const name = data.profile && data.profile.name;
|
||||||
|
if (!name || inParty() || invitesOpen || document.visibilityState === 'hidden') return;
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: typeof profileHeaders === 'function' ? profileHeaders(name) : {} });
|
||||||
|
const j = await res.json(); list = (j && j.ok && j.invites) || [];
|
||||||
|
} catch { return; }
|
||||||
|
let seen = []; try { seen = JSON.parse(ls.get(SEEN_KEY) || '[]'); } catch { /* none */ }
|
||||||
|
const fresh = list.filter((i) => force || !seen.includes(`${i.code}:${i.at}`));
|
||||||
|
if (!fresh.length || inParty()) return;
|
||||||
|
invitesOpen = true;
|
||||||
|
const markSeen = () => { ls.set(SEEN_KEY, JSON.stringify([...new Set([...seen, ...list.map((i) => `${i.code}:${i.at}`)])].slice(-50))); };
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `<p class="modal-text">${list.length === 1 ? 'You’ve been invited to a watch party.' : `You’ve been invited to ${list.length} watch parties.`}</p>
|
||||||
|
<div class="pt-invites">${list.map((i) => `<div class="pt-invite-row">
|
||||||
|
<div><b>${escapeHtml(i.host)}’s party</b><small>${i.song ? `Playing ${escapeHtml(i.song)} · ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' · host away'}</small></div>
|
||||||
|
<button class="rv-primary" type="button" data-join="${escapeHtml(i.code)}">Join</button></div>`).join('')}</div>`;
|
||||||
|
body.addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-join]'); if (!b) return;
|
||||||
|
markSeen(); invitesOpen = false; closeModal(); join(b.dataset.join, myName() || name);
|
||||||
|
});
|
||||||
|
showModal('🎉 Watch party invites', body, [{ label: 'Dismiss', onClick: () => { markSeen(); invitesOpen = false; closeModal(); } }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- presenting: a shared document / website everyone sees ----------
|
||||||
|
const canPresent = () => inParty() && (isHost() || st.allowControl);
|
||||||
|
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
|
||||||
|
// also follow the presenter's page.
|
||||||
|
function embedOf(url, page) {
|
||||||
|
let u; try { u = new URL(url); } catch { return { src: url, paged: false }; }
|
||||||
|
const g = u.hostname === 'docs.google.com' && u.pathname.match(/^\/(presentation|document|spreadsheets)\/d\/([\w-]+)/);
|
||||||
|
if (g && g[1] === 'presentation') return { src: `https://docs.google.com/presentation/d/${g[2]}/embed?start=false&loop=false&rm=minimal&slide=${page}`, paged: true, label: 'Google Slides' };
|
||||||
|
if (g) return { src: `https://docs.google.com/${g[1]}/d/${g[2]}/preview`, paged: false, label: g[1] === 'document' ? 'Google Doc' : 'Google Sheet' };
|
||||||
|
const d = u.hostname === 'drive.google.com' && u.pathname.match(/^\/file\/d\/([\w-]+)/);
|
||||||
|
if (d) return { src: `https://drive.google.com/file/d/${d[1]}/preview`, paged: false, label: 'Google Drive file' };
|
||||||
|
if (/\.pdf$/i.test(u.pathname)) { u.hash = `page=${page}`; return { src: u.href, paged: true, label: 'PDF' }; }
|
||||||
|
return { src: u.href, paged: false, label: u.hostname.replace(/^www\./, '') };
|
||||||
|
}
|
||||||
|
function presentEl() {
|
||||||
|
let el = $('partyPresent');
|
||||||
|
if (el) return el;
|
||||||
|
el = document.createElement('section');
|
||||||
|
el.id = 'partyPresent'; el.className = 'party-present hidden'; el.setAttribute('aria-label', 'Shared presentation');
|
||||||
|
el.innerHTML = `<header class="pp-bar">
|
||||||
|
<span class="pp-what"></span>
|
||||||
|
<span class="pp-pages"><button type="button" class="np-btn" data-pp="prev" aria-label="Previous page">‹</button><span class="pp-page"></span><button type="button" class="np-btn" data-pp="next" aria-label="Next page">›</button></span>
|
||||||
|
<span class="pp-rtools"><button type="button" class="np-btn" data-pp="smaller" aria-label="Smaller text">A−</button><button type="button" class="np-btn" data-pp="bigger" aria-label="Larger text">A+</button><button type="button" class="np-btn" data-pp="rotate" aria-pressed="false">⟳ Landscape</button></span>
|
||||||
|
<button type="button" class="np-btn" data-pp="mode"></button>
|
||||||
|
<a class="np-btn pp-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||||
|
<button type="button" class="np-btn" data-pp="hide">Hide</button>
|
||||||
|
</header>
|
||||||
|
<iframe class="pp-frame" title="Shared presentation" referrerpolicy="no-referrer" allow="fullscreen"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation"></iframe>
|
||||||
|
<div class="pp-reader" hidden></div>
|
||||||
|
<p class="pp-hint">Blank? Some sites don't allow embedding — tap Open to view it in a new tab.</p>`;
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-pp]'); if (!b || !st.present) return;
|
||||||
|
if (b.dataset.pp === 'hide') { st.presentHidden = true; renderPresent(); render(); return; }
|
||||||
|
if (b.dataset.pp === 'smaller' || b.dataset.pp === 'bigger') { readerSize(readerSize() + (b.dataset.pp === 'bigger' ? 2 : -2)); renderPresent(); return; }
|
||||||
|
if (b.dataset.pp === 'rotate') { setPresentLandscape(!el.classList.contains('landscape')); return; }
|
||||||
|
if (b.dataset.pp === 'mode') { st.viewMode[st.present.url] = viewOf(st.present) === 'reader' ? 'embed' : 'reader'; renderPresent(); return; }
|
||||||
|
if (!canPresent()) return;
|
||||||
|
const page = Math.max(1, (st.present.page || 1) + (b.dataset.pp === 'next' ? 1 : -1));
|
||||||
|
send({ type: 'present', present: { url: st.present.url, page, mode: st.present.mode } });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!st.present || st.presentHidden || !canPresent() || e.target.closest?.('input, textarea')) return;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'PageDown') el.querySelector('[data-pp="next"]').click();
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'PageUp') el.querySelector('[data-pp="prev"]').click();
|
||||||
|
});
|
||||||
|
document.body.append(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
// Reader view: the server turns the page into clean text; each device picks
|
||||||
|
// its own size and orientation, and can switch to the original page.
|
||||||
|
const viewOf = (p) => st.viewMode[p.url] || p.mode || 'embed';
|
||||||
|
const readerSize = (v) => {
|
||||||
|
if (v !== undefined) { ls.set('ytpReaderSize', String(Math.max(14, Math.min(44, v)))); }
|
||||||
|
return Number(ls.get('ytpReaderSize')) || 20;
|
||||||
|
};
|
||||||
|
async function setPresentLandscape(on) {
|
||||||
|
const el = $('partyPresent'); if (!el) return;
|
||||||
|
el.classList.toggle('landscape', !!on);
|
||||||
|
const b = el.querySelector('[data-pp="rotate"]'); b.setAttribute('aria-pressed', String(!!on)); b.textContent = on ? '⟲ Portrait' : '⟳ Landscape';
|
||||||
|
try {
|
||||||
|
if (on && document.documentElement.requestFullscreen && screen.orientation?.lock) { if (!document.fullscreenElement) await document.documentElement.requestFullscreen(); await screen.orientation.lock('landscape'); }
|
||||||
|
else if (!on) { try { screen.orientation?.unlock?.(); } catch { /* not locked */ } if (document.fullscreenElement) await document.exitFullscreen(); }
|
||||||
|
} catch { /* iOS: the CSS rotation covers it */ }
|
||||||
|
}
|
||||||
|
async function loadReader(url) {
|
||||||
|
if (st.reader.url === url && (st.reader.html || st.reader.loading)) return;
|
||||||
|
st.reader = { url, html: '', error: '', loading: true }; renderPresent();
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/party/${encodeURIComponent(st.code)}/reader`)).json();
|
||||||
|
if (st.reader.url !== url) return;
|
||||||
|
if (!j.ok) throw new Error(j.error || 'Could not read that page');
|
||||||
|
st.reader = { url, loading: false, error: '', html: `<article class="pp-article"><h1>${escapeHtml(j.title || '')}</h1>
|
||||||
|
<p class="pp-by">${escapeHtml([j.byline, j.site].filter(Boolean).join(' · '))}</p>${j.content}</article>` };
|
||||||
|
} catch (err) { if (st.reader.url === url) st.reader = { url, loading: false, html: '', error: err.message }; }
|
||||||
|
renderPresent();
|
||||||
|
}
|
||||||
|
function renderPresent() {
|
||||||
|
const p = st.present;
|
||||||
|
if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.classList.remove('landscape'); el.querySelector('.pp-frame').removeAttribute('src'); } return; }
|
||||||
|
const el = presentEl(), e = embedOf(p.url, p.page || 1);
|
||||||
|
const reading = viewOf(p) === 'reader';
|
||||||
|
el.classList.toggle('reading', reading);
|
||||||
|
el.querySelector('.pp-rtools').hidden = !reading;
|
||||||
|
el.querySelector('[data-pp="mode"]').textContent = reading ? 'Original' : 'Reader';
|
||||||
|
const box = el.querySelector('.pp-reader');
|
||||||
|
box.hidden = !reading;
|
||||||
|
el.querySelector('.pp-frame').hidden = reading;
|
||||||
|
el.querySelector('.pp-hint').hidden = reading;
|
||||||
|
if (reading) {
|
||||||
|
el.style.setProperty('--pp-size', readerSize() + 'px');
|
||||||
|
if (st.reader.url !== p.url) loadReader(p.url);
|
||||||
|
const r = st.reader;
|
||||||
|
const html = r.loading ? '<p class="pp-msg">Making the page easy to read…</p>'
|
||||||
|
: r.error ? `<p class="pp-msg">${escapeHtml(r.error)}</p><p class="pp-msg"><button type="button" class="np-btn" data-pp="mode">Show the original page</button></p>` : r.html;
|
||||||
|
if (box.dataset.for !== p.url + '|' + html.length) { box.innerHTML = html; box.dataset.for = p.url + '|' + html.length; }
|
||||||
|
el.querySelector('.pp-frame').removeAttribute('src');
|
||||||
|
}
|
||||||
|
el.classList.toggle('hidden', st.presentHidden);
|
||||||
|
el.querySelector('.pp-what').textContent = `${p.by || 'Host'} · ${e.label}`;
|
||||||
|
el.querySelector('.pp-pages').hidden = !e.paged;
|
||||||
|
el.querySelector('.pp-page').textContent = `${e.label === 'PDF' ? 'Page' : 'Slide'} ${p.page || 1}`;
|
||||||
|
for (const b of el.querySelectorAll('.pp-pages button')) b.hidden = !canPresent();
|
||||||
|
el.querySelector('.pp-open').href = p.url;
|
||||||
|
const frame = el.querySelector('.pp-frame');
|
||||||
|
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
||||||
|
}
|
||||||
|
function presentPrompt({ queue = false } = {}) {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
|
||||||
|
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${queue ? '' : escapeHtml(st.present?.url || '')}" />
|
||||||
|
${isHost() ? '<input id="ppTitle" maxlength="120" autocomplete="off" placeholder="Name it (optional), e.g. Sermon slides" style="margin-top:8px" />' : ''}
|
||||||
|
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
|
||||||
|
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
|
||||||
|
const go = (toQueue = false) => {
|
||||||
|
let url = ($('ppUrl').value || '').trim();
|
||||||
|
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||||
|
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
|
||||||
|
const special = embedOf(url, 1); // Google files and PDFs always embed
|
||||||
|
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed';
|
||||||
|
closeModal();
|
||||||
|
if (toQueue) { send({ type: 'pq', action: 'add', url, mode, title: ($('ppTitle')?.value || '').trim() }); toast('Added to Prepared ✓'); }
|
||||||
|
else send({ type: 'present', present: { url, page: 1, mode } });
|
||||||
|
};
|
||||||
|
showModal(queue ? '🖥 Prepare a presentation' : '🖥 Present to the party', body, [
|
||||||
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
...(isHost() ? [{ label: 'Add to Prepared', primary: queue, onClick: () => go(true) }] : []),
|
||||||
|
...(queue ? [] : [{ label: 'Present now', primary: true, onClick: () => go(false) }]),
|
||||||
|
]);
|
||||||
|
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(queue); } }); } }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
bindUi();
|
bindUi();
|
||||||
|
addEventListener('online', () => { if (inParty() && !st.replaced) connect(); });
|
||||||
|
addEventListener('offline', () => { if (inParty()) setStatus('Offline · waiting for your connection'); });
|
||||||
render();
|
render();
|
||||||
|
// iOS keeps a dead socket "open" after the PWA was in the background:
|
||||||
|
// after a long absence reconnect straight away instead of waiting for onclose.
|
||||||
|
setTimeout(() => checkInvites(), 2500);
|
||||||
|
setInterval(() => checkInvites(), 60_000);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
|
||||||
|
checkInvites();
|
||||||
|
if (!inParty() || st.replaced) return;
|
||||||
|
const ws = st.ws;
|
||||||
|
if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) {
|
||||||
|
st.ws = null; try { if (ws) ws.close(); } catch { /* gone */ }
|
||||||
|
setStatus('Reconnecting…'); connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
// Resume hosting after a reload.
|
// Resume hosting after a reload.
|
||||||
try {
|
try {
|
||||||
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
||||||
@@ -440,13 +830,13 @@
|
|||||||
if (code) {
|
if (code) {
|
||||||
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
||||||
if (myName()) join(code);
|
if (myName()) join(code);
|
||||||
else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
|
else { st.inviteCode = code; showPanel(); toast('Enter your name, then tap Join'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
||||||
open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
open, boot, guestActive, intercept, renderLyricsToggle, checkInvites, afterLoad: () => pushState(true),
|
||||||
state: () => ({
|
state: () => ({
|
||||||
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
||||||
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||||||
|
|||||||
@@ -521,3 +521,17 @@ contrast in Glass Stage at 320/390/768/1280px and dark/light/contrast themes. It
|
|||||||
also captures Classic, Bento Hub, and Framework7 Swipe at 390/1280px to guard
|
also captures Classic, Bento Hub, and Framework7 Swipe at 390/1280px to guard
|
||||||
their existing card presentation. Screenshots are written to
|
their existing card presentation. Screenshots are written to
|
||||||
`plans/rec-card-shots/`.
|
`plans/rec-card-shots/`.
|
||||||
|
|
||||||
|
## Admin operations screens
|
||||||
|
|
||||||
|
`npx playwright test -c playwright.admin-ops.config.js` checks the Performance,
|
||||||
|
P2P Devices, and Uploads admin tabs at 390/768/1280px in dark/light/contrast,
|
||||||
|
including AA body-text contrast, 44px controls, page overflow, and mobile
|
||||||
|
bottom-navigation clearance. Viewport screenshots are saved under
|
||||||
|
`plans/admin-ops-shots/` (ignored by Git).
|
||||||
|
|
||||||
|
For request-monitor overhead, run two local server instances against separate
|
||||||
|
data directories, one with `OPS_MONITOR=0` and one with monitoring enabled, then
|
||||||
|
run `OPS_BASE_URL=http://127.0.0.1:<off-port> OPS_ENABLED_URL=http://127.0.0.1:<on-port> node perf/admin-ops-bench.mjs`.
|
||||||
|
The harness interleaves 500 `/api/version` requests per mode and prints p50/p95,
|
||||||
|
mean latency, and the mean delta.
|
||||||
|
|||||||
29
perf/admin-ops-bench.mjs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Compare local /api/version latency with request monitoring enabled and disabled.
|
||||||
|
// Start two otherwise identical server instances and set OPS_BASE_URL/OPS_ENABLED_URL.
|
||||||
|
const urls = { disabled: process.env.OPS_BASE_URL, enabled: process.env.OPS_ENABLED_URL };
|
||||||
|
if (!urls.disabled || !urls.enabled) {
|
||||||
|
console.error('Set OPS_BASE_URL and OPS_ENABLED_URL to local server URLs.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const samples = { disabled: [], enabled: [] };
|
||||||
|
async function request(url) {
|
||||||
|
const start = performance.now();
|
||||||
|
const response = await fetch(`${url}/api/version`);
|
||||||
|
await response.arrayBuffer();
|
||||||
|
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
|
||||||
|
return performance.now() - start;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(urls)) for (let i = 0; i < 30; i++) await request(urls[key]);
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
for (const key of i % 2 ? ['enabled', 'disabled'] : ['disabled', 'enabled']) samples[key].push(await request(urls[key]));
|
||||||
|
}
|
||||||
|
const percentile = (values, p) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * p) - 1];
|
||||||
|
const result = Object.fromEntries(Object.entries(samples).map(([key, values]) => [key, {
|
||||||
|
requests: values.length,
|
||||||
|
p50Ms: Number(percentile(values, .5).toFixed(3)),
|
||||||
|
p95Ms: Number(percentile(values, .95).toFixed(3)),
|
||||||
|
meanMs: Number((values.reduce((a, b) => a + b, 0) / values.length).toFixed(3)),
|
||||||
|
}]));
|
||||||
|
console.log(JSON.stringify({ route: '/api/version', pairs: samples.disabled.length, result,
|
||||||
|
meanDeltaPercent: Number(((result.enabled.meanMs / result.disabled.meanMs - 1) * 100).toFixed(2)) }, null, 2));
|
||||||
BIN
plans/admin-tools-shots/audit.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
plans/admin-tools-shots/backups.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
plans/admin-tools-shots/bulk-contrast-1280.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
plans/admin-tools-shots/bulk-contrast-390.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
plans/admin-tools-shots/bulk-dark-1280.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
plans/admin-tools-shots/bulk-dark-390.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
plans/admin-tools-shots/bulk-light-1280.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
plans/admin-tools-shots/bulk-light-390.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
plans/admin-tools-shots/cache.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
plans/admin-tools-shots/index.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
plans/admin-tools-shots/library.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
plans/admin-tools-shots/lyric-search.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
plans/admin-tools-shots/quality.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
plans/admin-tools-shots/reports.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
plans/editor-redesign-shots/before-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
plans/editor-redesign-shots/before-mobile-dark.png
Normal file
|
After Width: | Height: | Size: 420 KiB |
BIN
plans/editor-redesign-shots/contrast-1280.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
plans/editor-redesign-shots/contrast-1920.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
plans/editor-redesign-shots/contrast-390.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
plans/editor-redesign-shots/contrast-768.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
plans/editor-redesign-shots/dark-1280.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
plans/editor-redesign-shots/dark-1920.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
plans/editor-redesign-shots/dark-390.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
plans/editor-redesign-shots/dark-768.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
plans/editor-redesign-shots/light-1280.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
plans/editor-redesign-shots/light-1920.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
plans/editor-redesign-shots/light-390.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
plans/editor-redesign-shots/light-768.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
39
plans/mysql-search-swoole-plan.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Plan: built-in search data in MySQL, and a Swoole port (benchmark first)
|
||||||
|
|
||||||
|
Owner request 2026-10-13. Nothing here is built yet. Phase 0 decides whether phases 2–3 happen at all.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
- Cold search is ~12.6 s (yt-dlp) and every request pays the ~250 ms home -> VPS -> WireGuard path several times (see memory `worship-slow-cloudflare-plan`).
|
||||||
|
- Today's "quick search" is `search_cache` (query string -> JSON blob, libsql) plus `video_catalog` / `GET /api/search/local`. It only helps for a query someone already ran.
|
||||||
|
- Goal: answer *any* worship-song query from a local index in a few ms, before touching YouTube, and handle more concurrent users per box.
|
||||||
|
|
||||||
|
## Phase 0 - measure before committing (1 day, no production change)
|
||||||
|
1. Capture a baseline on the current Bun/Hono server: `/api/search/local`, cached `/api/search`, `/api/version`, static asset, `/api/media/<id>` range request. Tool: `bombardier`/`oha` at 1/10/50/200 connections, from the homelab VM itself (removes the ISP path from the numbers).
|
||||||
|
2. Build a throwaway Swoole (OpenSwoole/Swoole 5, PHP 8.3, coroutine MySQL pool) service that serves the same three endpoints from MySQL. Base it on `personal/AppStarterSwooleLite`. Same box, same data.
|
||||||
|
3. Compare req/s, p50/p95/p99, RSS, CPU at each concurrency level. Also compare streaming a 10 MB slice (proxy path), since that is where Bun may already win or tie.
|
||||||
|
4. Decision gate: port only if Swoole is clearly better (>= 1.5x req/s or half the p99) on the endpoints that matter. If the gap is small, keep Bun and only do the MySQL index (phase 1).
|
||||||
|
|
||||||
|
## Phase 1 - MySQL search index (independent of the port)
|
||||||
|
- Table `song_index(video_id PK, title, uploader, title_norm, uploader_norm, duration, views, published, thumb, lang, source, updated_at)` with `FULLTEXT(title_norm, uploader_norm)` (ngram parser for CJK / Tagalog / Cebuano titles) plus a prefix index on `title_norm` for as-you-type.
|
||||||
|
- Seed from data we already own: `video_catalog`, `search_cache` result blobs (de-duplicated by video id), saved/cached media ids, lyrics-library ids, admin-curated lists. Optionally a one-off yt-dlp crawl of known worship channels.
|
||||||
|
- Query path: normalise (lower-case, strip accents/punctuation) -> `MATCH ... AGAINST` in boolean mode with title boost -> rank by relevance, then views/recency -> return card JSON in the same shape as `/api/search`. Answer from MySQL first; if fewer than N good hits, fall through to yt-dlp and write what comes back into `song_index` (self-growing).
|
||||||
|
- Background refresh job: re-resolve stale rows, drop dead videos.
|
||||||
|
- API: keep `/api/search` contract unchanged; add `source: "index"|"live"` to the response so the UI can show instantly and then top up.
|
||||||
|
- MySQL runs on the homelab (same host as the app, local socket, no WireGuard hop). Needs a decision on engine: reuse an existing Dokploy MySQL service or add a dedicated one.
|
||||||
|
- Tests: ranking fixtures (exact title, partial, typo, non-Latin), fall-through behaviour, de-dupe, and a latency budget (< 30 ms p95 at 10k rows, < 80 ms at 1M).
|
||||||
|
|
||||||
|
## Phase 2 - Swoole port (only if phase 0 says yes)
|
||||||
|
- Strangler approach, not a rewrite: Swoole service takes over read-heavy, stateless routes first (`/api/search*`, `/api/version`, static + manifest delivery, notes read). Bun keeps the stateful parts (yt-dlp pool, media cache writes, party/remote WebSockets, p2p hub, transcription) behind it; a small router (Traefik path rules or Swoole reverse-proxy) splits traffic.
|
||||||
|
- Move route groups one by one behind a flag; each move must pass the existing server tests re-expressed against the new service plus the Playwright suites unchanged.
|
||||||
|
- Things that do not port cleanly and stay in Bun for now: `yt-dlp` worker pool, WebSocket party/remote hubs, OPFS-related admin tooling.
|
||||||
|
- Rollback = flip the router rule back; both services share the same MySQL.
|
||||||
|
|
||||||
|
## Phase 3 - cutover and capacity
|
||||||
|
- Beta first (beta.worship.hesed.sbs), 1 week soak, then main.
|
||||||
|
- Publish capacity numbers (users per box) from the phase-0 harness re-run against the final stack.
|
||||||
|
|
||||||
|
## Risks / open decisions for the owner
|
||||||
|
- Does the homelab have the headroom for MySQL + Swoole next to the app (the USB cache drive is currently unmounted and the host froze on past USB incidents)?
|
||||||
|
- Is it OK to run a PHP service in this repo, or should the port live in a separate repo (`ytplayer-swoole`)?
|
||||||
|
- Index scope: only songs users have searched, or a proactive crawl of worship channels (more storage and yt-dlp load)?
|
||||||
|
- Mostly a latency problem is the network path, not the runtime: a Swoole port will not fix the 250 ms RTT. The Cloudflare tunnel plan still stands and is complementary.
|
||||||
3
playwright.admin-audit.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
const classic = require('./playwright.classic.config');
|
||||||
|
module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports|backups|lyrics-search|bulk-lyrics|tools-shots)\.spec\.js/ });
|
||||||
3
playwright.admin-deep-search.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
const classic = require('./playwright.classic.config');
|
||||||
|
module.exports = defineConfig({ ...classic, testMatch: /admin-deep-search\.spec\.js/ });
|
||||||
3
playwright.admin-ops.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
const classic = require('./playwright.classic.config');
|
||||||
|
module.exports = defineConfig({ ...classic, testMatch: /admin-ops\.spec\.js/ });
|
||||||
468
server/admin-tools.js
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { audit } from './audit-log.js';
|
||||||
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, join } from 'node:path';
|
||||||
|
|
||||||
|
const clientOf = db => typeof db?.execute === 'function' ? db : db?.db;
|
||||||
|
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
|
||||||
|
const number = value => Number(value) || 0;
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
const zipCrcTable = Uint32Array.from({ length: 256 }, (_, value) => {
|
||||||
|
let crc = value; for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
||||||
|
return crc >>> 0;
|
||||||
|
});
|
||||||
|
function zipStore(files) {
|
||||||
|
const local = [], central = []; let offset = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const name = Buffer.from(file.name), data = Buffer.from(file.content);
|
||||||
|
let crc = 0xffffffff; for (const byte of data) crc = zipCrcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); crc = (crc ^ 0xffffffff) >>> 0;
|
||||||
|
const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x0800, 6); header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(name.length, 26);
|
||||||
|
local.push(header, name, data);
|
||||||
|
const directory = Buffer.alloc(46); directory.writeUInt32LE(0x02014b50, 0); directory.writeUInt16LE(20, 4); directory.writeUInt16LE(20, 6); directory.writeUInt16LE(0x0800, 8); directory.writeUInt32LE(crc, 16); directory.writeUInt32LE(data.length, 20); directory.writeUInt32LE(data.length, 24); directory.writeUInt16LE(name.length, 28); directory.writeUInt32LE(offset, 42);
|
||||||
|
central.push(directory, name); offset += header.length + name.length + data.length;
|
||||||
|
}
|
||||||
|
const centralBytes = Buffer.concat(central), localBytes = Buffer.concat(local), end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(centralBytes.length, 12); end.writeUInt32LE(localBytes.length, 16);
|
||||||
|
return Buffer.concat([localBytes, centralBytes, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, backupDir = null, backfillStep = null, pullLyrics = null, queueWhisper = null, autoStartJobs = true }) {
|
||||||
|
const sql = clientOf(db);
|
||||||
|
|
||||||
|
const backupFile = date => join(backupDir || '.', `notes-${date}.json`);
|
||||||
|
const backupDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
|
||||||
|
async function readBackup(date) {
|
||||||
|
if (!backupDate(date)) return null;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await readFile(backupFile(date), 'utf8'));
|
||||||
|
if (!Array.isArray(data.notes) || data.notes.some(note => !note?.videoId || !['lyrics', 'chapters'].includes(note.kind) || !note.data)) return null;
|
||||||
|
return data;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
async function createBackup({ safety = false } = {}) {
|
||||||
|
if (!backupDir || typeof db.allNotes !== 'function') throw new Error('Backup storage unavailable');
|
||||||
|
await mkdir(safety ? join(backupDir, 'snapshots') : backupDir, { recursive: true });
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toISOString().slice(0, 10);
|
||||||
|
const file = safety ? join(backupDir, 'snapshots', `notes-${date}-${now.toISOString().slice(11, 19).replaceAll(':', '-')}-safety.json`) : backupFile(date);
|
||||||
|
const payload = { exportedAt: now.toISOString(), notes: await db.allNotes() };
|
||||||
|
await writeFile(file, JSON.stringify(payload));
|
||||||
|
return { file, date, notes: payload.notes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/admin/backups', adminAuth, async c => {
|
||||||
|
if (!backupDir) return c.json({ ok: false, error: 'Backup storage unavailable' }, 503);
|
||||||
|
let names = []; try { names = await readdir(backupDir); } catch {}
|
||||||
|
const backups = [];
|
||||||
|
for (const name of names.filter(value => /^notes-\d{4}-\d{2}-\d{2}\.json$/.test(value)).sort().reverse()) {
|
||||||
|
const date = name.slice(6, 16); const data = await readBackup(date); const info = await stat(backupFile(date)).catch(() => null);
|
||||||
|
backups.push({ date, bytes: info?.size || 0, exportedAt: data?.exportedAt || null, notes: data?.notes.length || 0, valid: !!data });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, backups }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/:date', adminAuth, async c => {
|
||||||
|
const data = await readBackup(c.req.param('date'));
|
||||||
|
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||||
|
const live = await db.allNotes();
|
||||||
|
const index = rows => new Map(rows.map(row => [`${row.videoId}:${row.kind}`, JSON.stringify(row.data)]));
|
||||||
|
const old = index(data.notes), current = index(live);
|
||||||
|
const added = [...old.keys()].filter(key => !current.has(key));
|
||||||
|
const removed = [...current.keys()].filter(key => !old.has(key));
|
||||||
|
const changed = [...old.keys()].filter(key => current.has(key) && old.get(key) !== current.get(key));
|
||||||
|
return c.json({ ok: true, date: c.req.param('date'), exportedAt: data.exportedAt, total: data.notes.length,
|
||||||
|
diff: { added: added.length, removed: removed.length, changed: changed.length, preview: [...changed.slice(0, 30).map(key => ({ key, change: 'changed' })), ...added.slice(0, 15).map(key => ({ key, change: 'only in backup' })), ...removed.slice(0, 15).map(key => ({ key, change: 'only live' }))] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/:date/download', adminAuth, async c => {
|
||||||
|
const data = await readBackup(c.req.param('date'));
|
||||||
|
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||||
|
c.header('Content-Disposition', `attachment; filename="notes-${c.req.param('date')}.json"`);
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/backups', adminAuth, async c => {
|
||||||
|
try {
|
||||||
|
const result = await createBackup();
|
||||||
|
await audit(db, 'admin', 'backup.create', result.date, { notes: result.notes });
|
||||||
|
return c.json({ ok: true, ...result });
|
||||||
|
} catch (error) { return c.json({ ok: false, error: error.message }, 503); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/backups/:date/restore', adminAuth, async c => {
|
||||||
|
const date = c.req.param('date'); const data = await readBackup(date);
|
||||||
|
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
|
||||||
|
let body = {}; try { body = await c.req.json(); } catch {}
|
||||||
|
const selected = body.videoId ? data.notes.filter(note => note.videoId === body.videoId) : data.notes;
|
||||||
|
if (body.videoId && !selected.length) return c.json({ ok: false, error: 'Song is not present in this backup' }, 404);
|
||||||
|
const safety = await createBackup({ safety: true });
|
||||||
|
for (const note of selected) await db.saveNote({ videoId: note.videoId, kind: note.kind, data: note.data, source: 'restore', updatedBy: `admin (backup ${date})`, force: true });
|
||||||
|
if (!body.videoId) {
|
||||||
|
const keep = new Set(data.notes.map(note => `${note.videoId}:${note.kind}`));
|
||||||
|
for (const note of await db.allNotes()) if (!keep.has(`${note.videoId}:${note.kind}`)) {
|
||||||
|
await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const safetySnapshot = basename(safety.file);
|
||||||
|
await audit(db, 'admin', 'backup.restore', date, { videoId: body.videoId || null, restored: selected.length, safetySnapshot });
|
||||||
|
return c.json({ ok: true, restored: selected.length, safetySnapshot });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/export/sqlite-dump', adminAuth, async c => {
|
||||||
|
const schema = await sql.execute("SELECT type,name,sql FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END,name");
|
||||||
|
const quote = value => {
|
||||||
|
if (value == null) return 'NULL';
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
||||||
|
if (typeof value === 'bigint') return String(value);
|
||||||
|
if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString('hex')}'`;
|
||||||
|
return `'${String(value).replaceAll("'", "''")}'`;
|
||||||
|
};
|
||||||
|
const statements = ['PRAGMA foreign_keys=OFF;', 'BEGIN TRANSACTION;'];
|
||||||
|
for (const table of schema.rows.filter(row => row.type === 'table')) {
|
||||||
|
statements.push(`${table.sql};`);
|
||||||
|
const rows = await sql.execute(`SELECT * FROM "${table.name.replaceAll('"', '""')}"`);
|
||||||
|
for (const row of rows.rows) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
statements.push(`INSERT INTO "${table.name}" (${columns.map(name => `"${name.replaceAll('"', '""')}"`).join(',')}) VALUES (${columns.map(name => quote(row[name])).join(',')});`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const object of schema.rows.filter(row => row.type !== 'table')) statements.push(`${object.sql};`);
|
||||||
|
statements.push('COMMIT;');
|
||||||
|
c.header('Content-Disposition', 'attachment; filename="ytplayer-notes-dump.sql"');
|
||||||
|
return c.text(statements.join('\n'), 200, { 'Content-Type': 'application/sql; charset=utf-8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/lyrics/quality', adminAuth, async c => {
|
||||||
|
const minLines = Math.max(1, Math.min(200, Number(c.req.query('minLines')) || 8));
|
||||||
|
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||||
|
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
|
||||||
|
const category = ['all', 'missing', 'short', 'automatic', 'untimed', 'reports'].includes(c.req.query('category')) ? c.req.query('category') : 'all';
|
||||||
|
const conditions = {
|
||||||
|
missing: 'n.video_id IS NULL',
|
||||||
|
short: 'n.video_id IS NOT NULL AND COALESCE(json_array_length(json_extract(n.data,\'$.lines\')),0) < ?',
|
||||||
|
automatic: "n.source='auto'",
|
||||||
|
untimed: "EXISTS (SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL)",
|
||||||
|
reports: "EXISTS (SELECT 1 FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open')",
|
||||||
|
};
|
||||||
|
const parts = category === 'all'
|
||||||
|
? [conditions.missing, conditions.short, conditions.automatic, conditions.untimed, conditions.reports]
|
||||||
|
: [conditions[category]];
|
||||||
|
const where = `WHERE (${parts.join(' OR ')})`;
|
||||||
|
const args = category === 'short' ? [minLines] : category === 'all' ? [minLines] : [];
|
||||||
|
const query = `SELECT m.id,m.card,m.updated_at,n.data AS lyrics,n.source AS lyricSource,n.updated_at AS lyricUpdated,
|
||||||
|
COALESCE(json_array_length(json_extract(n.data,'$.lines')),0) AS lineCount,
|
||||||
|
EXISTS(SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL) AS untimed,
|
||||||
|
(SELECT COUNT(*) FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open') AS openReports
|
||||||
|
FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics'
|
||||||
|
${where} ORDER BY openReports DESC,lyricUpdated ASC,m.updated_at DESC LIMIT ? OFFSET ?`;
|
||||||
|
const rows = await sql.execute({ sql: query, args: [...args, limit, offset] });
|
||||||
|
const count = await sql.execute({ sql: `SELECT COUNT(*) AS total FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' ${where}`, args });
|
||||||
|
const songs = rows.rows.map(row => {
|
||||||
|
const card = parse(row.card);
|
||||||
|
let untimed = !!row.untimed;
|
||||||
|
let lines = number(row.lineCount);
|
||||||
|
if (row.lyrics) {
|
||||||
|
const data = parse(row.lyrics); lines ||= Array.isArray(data.lines) ? data.lines.length : 0;
|
||||||
|
if (Array.isArray(data.lines)) untimed ||= data.lines.some(line => line.t == null);
|
||||||
|
}
|
||||||
|
const reasons = [];
|
||||||
|
if (!row.lyrics) reasons.push('missing');
|
||||||
|
else if (lines < minLines) reasons.push('short');
|
||||||
|
if (row.lyricSource === 'auto') reasons.push('automatic');
|
||||||
|
if (untimed) reasons.push('untimed');
|
||||||
|
if (number(row.openReports)) reasons.push('reports');
|
||||||
|
return { id: row.id, title: card.title || row.id, channel: card.channel || '', lines, source: row.lyricSource || null, untimed, openReports: number(row.openReports), reasons, updatedAt: number(row.lyricUpdated) };
|
||||||
|
});
|
||||||
|
return c.json({ ok: true, minLines, category, offset, limit, total: number(count.rows[0]?.total), songs }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/lyrics/search', adminAuth, async c => {
|
||||||
|
const query = String(c.req.query('q') || '').trim().slice(0, 120);
|
||||||
|
const hasLyrics = ['yes', 'no', 'all'].includes(c.req.query('hasLyrics')) ? c.req.query('hasLyrics') : 'yes';
|
||||||
|
const hasChapters = ['yes', 'no', 'all'].includes(c.req.query('hasChapters')) ? c.req.query('hasChapters') : 'all';
|
||||||
|
const limit = Math.max(1, Math.min(50, Number(c.req.query('limit')) || 30));
|
||||||
|
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||||
|
let hits = [], total = 0;
|
||||||
|
if (query && hasLyrics !== 'no') {
|
||||||
|
const chapterOptions = { hasChapters: hasChapters === 'yes', withoutChapters: hasChapters === 'no' };
|
||||||
|
hits = await db.searchNoteLyrics(query, { limit, offset, ...chapterOptions });
|
||||||
|
total = await db.countNoteLyrics(query, chapterOptions);
|
||||||
|
}
|
||||||
|
const terms = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||||
|
if (!query || hasLyrics === 'no') {
|
||||||
|
const parts = [];
|
||||||
|
const args = [];
|
||||||
|
if (hasLyrics === 'yes') parts.push("l.video_id IS NOT NULL AND l.data<>'{}'");
|
||||||
|
if (hasLyrics === 'no') parts.push('l.video_id IS NULL');
|
||||||
|
if (hasChapters === 'yes') parts.push("EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
|
||||||
|
if (hasChapters === 'no') parts.push("NOT EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
|
||||||
|
for (const term of terms) { parts.push('lower(m.hay) LIKE ?'); args.push(`%${term}%`); }
|
||||||
|
const where = parts.length ? `WHERE ${parts.join(' AND ')}` : '';
|
||||||
|
const count = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where}`, args });
|
||||||
|
const rows = await sql.execute({ sql: `SELECT m.id,m.card,l.data AS lyrics,l.rev,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters') AS chapters FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where} ORDER BY m.updated_at DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] });
|
||||||
|
total = number(count.rows[0]?.n);
|
||||||
|
hits = rows.rows.map(row => ({ videoId: row.id, snippet: parse(row.lyrics).lines?.slice(0, 3).map(line => line.text).join(' … ') || parse(row.card).title || row.id, rev: number(row.rev), hasChapters: !!row.chapters }));
|
||||||
|
}
|
||||||
|
if (hits.length && query && hasLyrics !== 'no') {
|
||||||
|
const ids = hits.map(item => item.videoId);
|
||||||
|
const rows = await sql.execute({ sql: `SELECT n.video_id,n.data,n.rev,m.card,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=n.video_id AND ch.kind='chapters') AS chapters FROM video_notes n LEFT JOIN video_meta m ON m.id=n.video_id WHERE n.kind='lyrics' AND n.video_id IN (${ids.map(() => '?').join(',')})`, args: ids });
|
||||||
|
const byId = new Map(rows.rows.map(row => [row.video_id, row]));
|
||||||
|
hits = hits.map(hit => {
|
||||||
|
const row = byId.get(hit.videoId), card = parse(row?.card);
|
||||||
|
return { ...hit, title: card.title || hit.videoId, channel: card.channel || '', rev: number(row?.rev), hasChapters: !!row?.chapters };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, query, hasLyrics, hasChapters, total, limit, offset, results: hits }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/lyrics/search/rebuild', adminAuth, async c => {
|
||||||
|
if (typeof db.rebuildLyricsSearch !== 'function') return c.json({ ok: false, error: 'Lyrics search rebuild is unavailable.' }, 503);
|
||||||
|
const indexed = await db.rebuildLyricsSearch();
|
||||||
|
await audit(db, 'admin', 'lyrics.search.rebuild', '', { indexed });
|
||||||
|
return c.json({ ok: true, indexed });
|
||||||
|
});
|
||||||
|
|
||||||
|
const librarySelect = `SELECT id,title,channel,hay,lyrics,rev,cacheStatus FROM (
|
||||||
|
SELECT m.id,COALESCE(json_extract(m.card,'$.title'),m.id) AS title,COALESCE(json_extract(m.card,'$.channel'),'') AS channel,m.hay,
|
||||||
|
n.data AS lyrics,n.rev AS rev,c.status AS cacheStatus FROM video_meta m
|
||||||
|
LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' LEFT JOIN media_cache c ON c.video_id=m.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT u.id,u.title,COALESCE(u.artist,''),lower(u.title||' '||COALESCE(u.artist,'')),n.data,n.rev,NULL
|
||||||
|
FROM uploads u LEFT JOIN video_notes n ON n.video_id=u.id AND n.kind='lyrics'
|
||||||
|
)`;
|
||||||
|
function lyricsFilter(q) {
|
||||||
|
const words = String(q || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||||
|
return { where: words.length ? `WHERE ${words.map(() => 'lower(hay) LIKE ?').join(' AND ')}` : '', args: words.map(word => `%${word}%`) };
|
||||||
|
}
|
||||||
|
app.get('/api/admin/lyrics/bulk/library', adminAuth, async c => {
|
||||||
|
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
|
||||||
|
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||||
|
const q = String(c.req.query('q') || '').slice(0, 120), filter = lyricsFilter(q);
|
||||||
|
const [count, rows] = await Promise.all([
|
||||||
|
sql.execute({ sql: `SELECT COUNT(*) AS total FROM (${librarySelect}) library ${filter.where}`, args: filter.args }),
|
||||||
|
sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT ? OFFSET ?`, args: [...filter.args, limit, offset] }),
|
||||||
|
]);
|
||||||
|
return c.json({ ok: true, q, total: number(count.rows[0]?.total), limit, offset, songs: rows.rows.map(row => {
|
||||||
|
const lyrics = parse(row.lyrics), lines = Array.isArray(lyrics.lines) ? lyrics.lines.length : 0;
|
||||||
|
return { id: row.id, title: row.title || row.id, channel: row.channel || '', lines, rev: number(row.rev), cacheStatus: row.cacheStatus || null };
|
||||||
|
}) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/lyrics/jobs', adminAuth, async c => {
|
||||||
|
let body = {}; try { body = await c.req.json(); } catch {}
|
||||||
|
const action = ['lrclib', 'whisper', 'export', 'clear'].includes(body.action) ? body.action : '';
|
||||||
|
if (!action) return c.json({ ok: false, error: 'Choose LRCLIB, Whisper, export, or clear.' }, 400);
|
||||||
|
let ids = Array.isArray(body.ids) ? body.ids : [];
|
||||||
|
if (body.allFiltered === true) {
|
||||||
|
const filter = lyricsFilter(String(body.query || '').slice(0, 120));
|
||||||
|
const rows = await sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT 500`, args: filter.args });
|
||||||
|
ids = rows.rows.map(row => row.id);
|
||||||
|
}
|
||||||
|
ids = [...new Set(ids.map(String).filter(id => /^[\w-]{11}$/.test(id) || /^upl_[a-f0-9]{12}$/.test(id)))].slice(0, 500);
|
||||||
|
if (!ids.length) return c.json({ ok: false, error: 'Select at least one song.' }, 400);
|
||||||
|
if (action === 'clear' && body.confirmClear !== true) return c.json({ ok: false, error: 'Clearing lyrics requires explicit confirmation.' }, 400);
|
||||||
|
const id = crypto.randomUUID(), now = Math.floor(Date.now() / 1000);
|
||||||
|
const state = { done: 0, total: ids.length, results: [], logs: [], fileReady: false };
|
||||||
|
await sql.execute({ sql: 'INSERT INTO admin_lyrics_jobs(id,action,status,ids,state,created_at,updated_at) VALUES(?,?,\'queued\',?,?,?,?)', args: [id, action, JSON.stringify(ids), JSON.stringify(state), now, now] });
|
||||||
|
await audit(db, 'admin', 'lyrics.bulk.start', id, { action, count: ids.length });
|
||||||
|
return c.json({ ok: true, job: { id, action, status: 'queued', state } }, 202);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/lyrics/jobs', adminAuth, async c => {
|
||||||
|
const rows = await sql.execute('SELECT * FROM admin_lyrics_jobs ORDER BY created_at DESC LIMIT 30');
|
||||||
|
return c.json({ ok: true, jobs: rows.rows.map(row => ({ id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) })) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
app.get('/api/admin/lyrics/jobs/:id', adminAuth, async c => {
|
||||||
|
const row = (await sql.execute({ sql: 'SELECT * FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
|
||||||
|
return row ? c.json({ ok: true, job: { id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) } }) : c.json({ ok: false, error: 'Job not found.' }, 404);
|
||||||
|
});
|
||||||
|
app.post('/api/admin/lyrics/jobs/:id/:action', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'), action = c.req.param('action');
|
||||||
|
if (!['pause', 'resume', 'cancel'].includes(action)) return c.json({ ok: false, error: 'Unknown job action.' }, 400);
|
||||||
|
const row = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [id] })).rows[0];
|
||||||
|
if (!row) return c.json({ ok: false, error: 'Job not found.' }, 404);
|
||||||
|
const next = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
|
||||||
|
if (action === 'pause' && !['queued', 'running'].includes(row.status) || action === 'resume' && row.status !== 'paused' || action === 'cancel' && !['queued', 'running', 'paused'].includes(row.status)) return c.json({ ok: false, error: `Cannot ${action} a ${row.status} job.` }, 409);
|
||||||
|
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET status=?,updated_at=? WHERE id=?', args: [next, Math.floor(Date.now() / 1000), id] });
|
||||||
|
await audit(db, 'admin', `lyrics.bulk.${action}`, id, { from: row.status, to: next });
|
||||||
|
return c.json({ ok: true, status: next });
|
||||||
|
});
|
||||||
|
app.get('/api/admin/lyrics/jobs/:id/export', adminAuth, async c => {
|
||||||
|
const row = (await sql.execute({ sql: 'SELECT action,status,state FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
|
||||||
|
const state = parse(row?.state);
|
||||||
|
if (!row || row.action !== 'export' || row.status !== 'complete' || !state.fileReady || !backupDir) return c.json({ ok: false, error: 'Export is not ready.' }, 404);
|
||||||
|
try { const data = await readFile(join(backupDir, 'bulk-lyrics', `${c.req.param('id')}.zip`)); return new Response(data, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="lyrics-${c.req.param('id')}.zip"` } }); }
|
||||||
|
catch { return c.json({ ok: false, error: 'Export file is unavailable.' }, 404); }
|
||||||
|
});
|
||||||
|
|
||||||
|
let workerBusy = false;
|
||||||
|
let activeJobId = null;
|
||||||
|
async function runBulkJob() {
|
||||||
|
if (workerBusy) return;
|
||||||
|
workerBusy = true;
|
||||||
|
try {
|
||||||
|
const row = (await sql.execute("SELECT * FROM admin_lyrics_jobs WHERE status IN ('queued','running') ORDER BY created_at,id LIMIT 1")).rows[0];
|
||||||
|
if (!row) return;
|
||||||
|
activeJobId = row.id;
|
||||||
|
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='running',updated_at=? WHERE id=? AND status='queued'", args: [Math.floor(Date.now() / 1000), row.id] });
|
||||||
|
const ids = parse(row.ids), state = parse(row.state); state.total = ids.length; state.results ||= []; state.logs ||= [];
|
||||||
|
for (let index = number(state.done); index < ids.length; index++) {
|
||||||
|
const current = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [row.id] })).rows[0]?.status;
|
||||||
|
if (current !== 'running') return;
|
||||||
|
const videoId = ids[index]; let result;
|
||||||
|
try {
|
||||||
|
if (row.action === 'lrclib') result = pullLyrics ? await pullLyrics(videoId) : { ok: false, error: 'LRCLIB worker is unavailable.' };
|
||||||
|
else if (row.action === 'whisper') {
|
||||||
|
const queued = queueWhisper ? await queueWhisper(videoId) : { status: 503, body: { ok: false, error: 'Whisper worker is unavailable.' } };
|
||||||
|
result = { ...(queued.body || {}), ok: queued.status < 400 && queued.body?.ok, httpStatus: queued.status };
|
||||||
|
} else if (row.action === 'clear') {
|
||||||
|
const saved = await db.saveNote({ videoId, kind: 'lyrics', data: { lines: [], tags: [], offset: 0 }, source: 'admin-bulk-clear', updatedBy: 'admin bulk clear', force: true });
|
||||||
|
const resolved = await db.autoResolveFlags(videoId, new Set());
|
||||||
|
await audit(db, 'admin', 'lyrics.clear', videoId, { rev: saved.rev, resolvedReports: resolved, undoViaRevision: saved.rev - 1 });
|
||||||
|
result = { ok: true, rev: saved.rev, resolvedReports: resolved };
|
||||||
|
} else {
|
||||||
|
const note = (await db.getNotes(videoId)).lyrics;
|
||||||
|
result = note ? { ok: true, lines: note.data.lines?.length || 0 } : { ok: false, error: 'No saved lyrics to export.' };
|
||||||
|
}
|
||||||
|
} catch (error) { result = { ok: false, error: String(error.message || error).slice(0, 300) }; }
|
||||||
|
const item = { id: videoId, ok: !!result?.ok, message: result?.ok ? (result.lines != null ? `${result.lines} lines` : 'Done') : result?.error || 'Action failed.' };
|
||||||
|
state.done = index + 1; state.results.push(item); state.logs.push({ at: Date.now(), ...item }); if (state.logs.length > 80) state.logs.splice(0, state.logs.length - 80);
|
||||||
|
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET state=?,updated_at=? WHERE id=?', args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
|
||||||
|
if (row.action === 'lrclib') await sleep(1200);
|
||||||
|
}
|
||||||
|
if (row.action === 'export') {
|
||||||
|
const files = [];
|
||||||
|
for (const videoId of ids) {
|
||||||
|
const note = (await db.getNotes(videoId)).lyrics; if (!note?.data?.lines?.length) continue;
|
||||||
|
let title = videoId;
|
||||||
|
if (videoId.startsWith('upl_')) title = (await db.getUpload(videoId))?.title || videoId;
|
||||||
|
else {
|
||||||
|
const card = (await sql.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [videoId] })).rows[0]; title = parse(card?.card).title || videoId;
|
||||||
|
}
|
||||||
|
const safe = String(title).normalize('NFKC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 100) || videoId;
|
||||||
|
const lrc = note.data.lines.map(line => line.t == null ? line.text : `[${String(Math.floor(line.t / 60)).padStart(2, '0')}:${(line.t % 60).toFixed(2).padStart(5, '0')}]${line.text}`).join('\n') + '\n';
|
||||||
|
files.push({ name: `${safe}__${videoId}.lrc`, content: lrc });
|
||||||
|
}
|
||||||
|
if (!backupDir) throw Error('Export storage is unavailable.');
|
||||||
|
await mkdir(join(backupDir, 'bulk-lyrics'), { recursive: true });
|
||||||
|
await writeFile(join(backupDir, 'bulk-lyrics', `${row.id}.zip`), zipStore(files));
|
||||||
|
state.fileReady = true; state.exportedFiles = files.length;
|
||||||
|
}
|
||||||
|
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='complete',state=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[admin-tools] lyrics job failed:', error.message);
|
||||||
|
if (activeJobId) {
|
||||||
|
try {
|
||||||
|
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='failed',error=?,updated_at=? WHERE id=? AND status='running'", args: [String(error.message || error).slice(0, 300), Math.floor(Date.now() / 1000), activeJobId] });
|
||||||
|
await audit(db, 'admin', 'lyrics.bulk.failed', activeJobId, { error: String(error.message || error).slice(0, 300) });
|
||||||
|
} catch (persistError) { console.warn('[admin-tools] could not persist lyrics job failure:', persistError.message); }
|
||||||
|
}
|
||||||
|
} finally { activeJobId = null; workerBusy = false; }
|
||||||
|
}
|
||||||
|
if (autoStartJobs) {
|
||||||
|
// The table is created by the schema setup, which may still be running at
|
||||||
|
// start-up: retry until it exists rather than skipping the resume.
|
||||||
|
const resume = (tries = 0) => sql.execute("UPDATE admin_lyrics_jobs SET status='queued',updated_at=unixepoch() WHERE status='running'").then(() => runBulkJob())
|
||||||
|
.catch(error => { if (/no such table/.test(error.message) && tries < 30) setTimeout(() => resume(tries + 1), 2000).unref?.(); else console.warn('[admin-tools] could not resume lyrics jobs:', error.message); });
|
||||||
|
resume();
|
||||||
|
const timer = setInterval(runBulkJob, 1000); timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/admin/cache/health', adminAuth, async c => {
|
||||||
|
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
|
||||||
|
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||||
|
const rows = await sql.execute({ sql: "SELECT video_id,status,size,last_access,updated_at,gen,pinned FROM media_cache ORDER BY CASE WHEN status='ready' THEN 0 ELSE 1 END,last_access ASC LIMIT ? OFFSET ?", args: [limit, offset] });
|
||||||
|
const total = await sql.execute('SELECT COUNT(*) AS total FROM media_cache');
|
||||||
|
const grouped = await sql.execute('SELECT status,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes,COALESCE(SUM(CASE WHEN pinned=1 THEN size ELSE 0 END),0) AS pinnedBytes FROM media_cache GROUP BY status');
|
||||||
|
const bytes = grouped.rows.reduce((sum, row) => sum + number(row.bytes), 0);
|
||||||
|
const pinnedBytes = grouped.rows.reduce((sum, row) => sum + number(row.pinnedBytes), 0);
|
||||||
|
const budget = Math.max(0, Number(process.env.MEDIA_CACHE_MAX_BYTES) || 10 * 1024 ** 3);
|
||||||
|
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
|
||||||
|
return c.json({ ok: true, budget, pinnedBudget, bytes, pinnedBytes, utilization: budget ? bytes / budget : 0,
|
||||||
|
statuses: grouped.rows.map(row => ({ status: row.status, count: number(row.count), bytes: number(row.bytes) })),
|
||||||
|
total: number(total.rows[0]?.total), limit, offset,
|
||||||
|
media: rows.rows.map(row => ({ id: row.video_id, status: row.status, bytes: number(row.size), lastAccess: number(row.last_access), updatedAt: number(row.updated_at), gen: number(row.gen), pinned: !!row.pinned })) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/cache/:id/evict', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id)) return c.json({ ok: false, error: 'invalid media id' }, 400);
|
||||||
|
const row = await db.getMedia(id);
|
||||||
|
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
|
||||||
|
if (row.pinned) return c.json({ ok: false, error: 'Unpin this copy before evicting it.' }, 409);
|
||||||
|
if (!media?.evictNow) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
|
||||||
|
await media.evictNow(id);
|
||||||
|
await audit(db, 'admin', 'cache.evict', id, { bytes: number(row.size) });
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/cache/:id/pin', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
let body = {}; try { body = await c.req.json(); } catch {}
|
||||||
|
if (body.pinned !== true && body.pinned !== false) return c.json({ ok: false, error: 'pinned must be boolean' }, 400);
|
||||||
|
const target = await sql.execute({ sql: "SELECT size,pinned,status FROM media_cache WHERE video_id=?", args: [id] });
|
||||||
|
const row = target.rows[0];
|
||||||
|
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
|
||||||
|
if (body.pinned && row.status !== 'ready') return c.json({ ok: false, error: 'Only ready cache copies can be pinned.' }, 409);
|
||||||
|
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
|
||||||
|
if (body.pinned && !row.pinned) {
|
||||||
|
const totals = await sql.execute('SELECT COALESCE(SUM(size),0) AS bytes FROM media_cache WHERE pinned=1');
|
||||||
|
if (number(totals.rows[0]?.bytes) + number(row.size) > pinnedBudget) return c.json({ ok: false, error: 'Pinned cache byte cap exceeded.' }, 409);
|
||||||
|
}
|
||||||
|
await sql.execute({ sql: 'UPDATE media_cache SET pinned=? WHERE video_id=?', args: [body.pinned ? 1 : 0, id] });
|
||||||
|
await audit(db, 'admin', body.pinned ? 'cache.pin' : 'cache.unpin', id, { bytes: number(row.size) });
|
||||||
|
return c.json({ ok: true, pinned: body.pinned });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/index/health', adminAuth, async c => {
|
||||||
|
const [stats, sources, latest, backfill] = await Promise.all([
|
||||||
|
sql.execute(`SELECT COUNT(*) AS total,SUM(CASE WHEN url IS NOT NULL AND url<>'' THEN 1 ELSE 0 END) AS urls,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_details d WHERE d.video_id=video_meta.id) THEN 1 ELSE 0 END) AS details,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_thumbnails t WHERE t.video_id=video_meta.id AND t.data IS NOT NULL) THEN 1 ELSE 0 END) AS thumbnails FROM video_meta`),
|
||||||
|
sql.execute('SELECT source,COUNT(*) AS videos,SUM(discoveries) AS discoveries,MAX(last_seen) AS lastSeen FROM video_meta_sources GROUP BY source ORDER BY videos DESC'),
|
||||||
|
sql.execute('SELECT id,status,created_at,updated_at,state,error FROM deep_search_jobs ORDER BY created_at DESC LIMIT 1'),
|
||||||
|
sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source'),
|
||||||
|
]);
|
||||||
|
const row = stats.rows[0] || {}, total = number(row.total);
|
||||||
|
const last = latest.rows[0];
|
||||||
|
return c.json({ ok: true, total, urlCount: number(row.urls), detailCount: number(row.details), thumbnailCount: number(row.thumbnails),
|
||||||
|
urlCoverage: total ? number(row.urls) / total : 0, detailCoverage: total ? number(row.details) / total : 0, thumbnailCoverage: total ? number(row.thumbnails) / total : 0,
|
||||||
|
sources: sources.rows.map(x => ({ source: x.source, videos: number(x.videos), discoveries: number(x.discoveries), lastSeen: number(x.lastSeen) })),
|
||||||
|
lastRun: last ? { id: last.id, status: last.status, createdAt: number(last.created_at), updatedAt: number(last.updated_at), error: last.error || null, progress: parse(last.state) } : null,
|
||||||
|
backfill: backfill.rows.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/index/backfill', adminAuth, async c => {
|
||||||
|
if (!backfillStep) return c.json({ ok: false, error: 'Catalog backfill is unavailable.' }, 503);
|
||||||
|
const processed = await backfillStep(100);
|
||||||
|
const state = (await sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source')).rows;
|
||||||
|
await audit(db, 'admin', 'index.backfill', '', { processed });
|
||||||
|
return c.json({ ok: true, processed, backfill: state.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/index/cleanup', adminAuth, async c => {
|
||||||
|
let body = {}; try { body = await c.req.json(); } catch {}
|
||||||
|
const days = Math.max(180, Math.min(3650, Number(body.days) || 365));
|
||||||
|
const cutoff = Date.now() - days * 86400_000;
|
||||||
|
const candidates = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m WHERE m.updated_at<?
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=m.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=m.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=m.id)`, args: [cutoff] });
|
||||||
|
const removed = number(candidates.rows[0]?.n);
|
||||||
|
await sql.execute({ sql: `DELETE FROM video_meta WHERE updated_at<?
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=video_meta.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=video_meta.id)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=video_meta.id)`, args: [cutoff] });
|
||||||
|
await sql.batch([
|
||||||
|
'DELETE FROM video_meta_sources WHERE video_id NOT IN (SELECT id FROM video_meta)',
|
||||||
|
'DELETE FROM video_thumbnails WHERE video_id NOT IN (SELECT id FROM video_meta)',
|
||||||
|
'DELETE FROM video_channels WHERE video_id NOT IN (SELECT id FROM video_meta)',
|
||||||
|
'DELETE FROM video_details WHERE video_id NOT IN (SELECT id FROM video_meta)',
|
||||||
|
], 'write');
|
||||||
|
await audit(db, 'admin', 'index.cleanup', '', { days, removed });
|
||||||
|
return c.json({ ok: true, removed, days });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { runBulkJob };
|
||||||
|
|
||||||
|
}
|
||||||
157
server/admin-tools.test.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-admin-tools-'));
|
||||||
|
process.env.DB_PATH = join(root, 'admin.db');
|
||||||
|
process.env.MEDIA_CACHE_PINNED_MAX_BYTES = '1000';
|
||||||
|
const db = await import('./db.js');
|
||||||
|
const { registerAdminToolsRoutes } = await import('./admin-tools.js');
|
||||||
|
const app = new Hono();
|
||||||
|
const backupDir = join(root, 'backups');
|
||||||
|
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
|
||||||
|
let tools;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await db.initDb();
|
||||||
|
tools = registerAdminToolsRoutes(app, { db, adminAuth: auth, backupDir, backfillStep: async () => 3,
|
||||||
|
pullLyrics: async () => ({ ok: true, lines: 2 }), queueWhisper: async () => ({ status: 202, body: { ok: true, job: { status: 'queued' } } }), autoStartJobs: false });
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['QualMiss001', JSON.stringify({ title: 'Missing lyric sample', channel: 'Choir' }), 'missing lyric sample choir', 1, Date.now()] });
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['Untimely001', JSON.stringify({ title: 'Untimed sample', channel: 'Band' }), 'untimed sample band', 1, Date.now()] });
|
||||||
|
await db.saveNote({ videoId: 'Untimely001', kind: 'lyrics', data: { lines: [{ t: null, text: 'A line' }, { t: 4, text: 'Next' }] }, source: 'user', force: true });
|
||||||
|
await db.db.execute({ sql: "INSERT INTO media_cache(video_id,status,size,gen) VALUES('CacheItem001','ready',700,1),('CacheItem002','ready',400,1)" });
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at,url) VALUES(?,?,?,?,?,?)', args: ['IndexSong001', JSON.stringify({ title: 'Indexed song' }), 'indexed song', 1, Date.now(), 'https://www.youtube.com/watch?v=IndexSong001'] });
|
||||||
|
await db.db.execute({ sql: "INSERT INTO video_meta_sources(video_id,source,last_seen) VALUES('IndexSong001','deep',?)", args: [Date.now()] });
|
||||||
|
await db.db.execute({ sql: "INSERT INTO video_details(video_id,metadata,updated_at) VALUES('IndexSong001','{}',?)", args: [Date.now()] });
|
||||||
|
await db.db.execute({ sql: "INSERT INTO video_thumbnails(video_id,url,data,mime,size,fetched_at) VALUES('IndexSong001','https://i.ytimg.com/vi/IndexSong001/hqdefault.jpg',x'01','image/jpeg',1,?)", args: [Date.now()] });
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,1)', args: ['OldOrphan01', JSON.stringify({ title: 'Old orphan' }), 'old orphan', 1] });
|
||||||
|
await db.db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES('job-1','complete','[]','{}','{\"queriesDone\":2,\"newVideos\":5}',1,2)" });
|
||||||
|
});
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('lyrics quality queue', () => {
|
||||||
|
test('requires admin and returns missing, untimed and short-lyrics reasons in pages', async () => {
|
||||||
|
expect((await app.request('/api/admin/lyrics/quality')).status).toBe(401);
|
||||||
|
const headers = { 'x-admin': 'yes' };
|
||||||
|
const missing = await (await app.request('/api/admin/lyrics/quality?category=missing', { headers })).json();
|
||||||
|
expect(missing.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(missing.songs.find(song => song.id === 'QualMiss001')).toMatchObject({ id: 'QualMiss001', reasons: ['missing'] });
|
||||||
|
const untimed = await (await app.request('/api/admin/lyrics/quality?category=untimed', { headers })).json();
|
||||||
|
expect(untimed.songs[0]).toMatchObject({ id: 'Untimely001', lines: 2, untimed: true });
|
||||||
|
const short = await (await app.request('/api/admin/lyrics/quality?category=short&minLines=3', { headers })).json();
|
||||||
|
expect(short.songs.find(song => song.id === 'Untimely001').reasons).toContain('short');
|
||||||
|
const page = await (await app.request('/api/admin/lyrics/quality?limit=1&offset=1', { headers })).json();
|
||||||
|
expect(page.songs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinning is authorized and enforces the configured pinned-byte cap', async () => {
|
||||||
|
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||||
|
expect((await app.request('/api/admin/cache/CacheItem001/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) })).status).toBe(200);
|
||||||
|
const over = await app.request('/api/admin/cache/CacheItem002/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) });
|
||||||
|
expect(over.status).toBe(409);
|
||||||
|
expect((await over.json()).error).toContain('byte cap');
|
||||||
|
const refused = await app.request('/api/admin/cache/CacheItem001/evict', { method: 'POST', headers, body: '{}' });
|
||||||
|
expect(refused.status).toBe(409);
|
||||||
|
const health = await (await app.request('/api/admin/cache/health', { headers: { 'x-admin': 'yes' } })).json();
|
||||||
|
expect(health.pinnedBytes).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index health reports coverage, source and deep-search data; repair actions are audited', async () => {
|
||||||
|
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||||
|
const health = await (await app.request('/api/admin/index/health', { headers })).json();
|
||||||
|
expect(health.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(health.urlCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(health.detailCount).toBe(1);
|
||||||
|
expect(health.thumbnailCount).toBe(1);
|
||||||
|
expect(health.sources[0].source).toBe('deep');
|
||||||
|
expect(health.lastRun.progress.queriesDone).toBe(2);
|
||||||
|
const step = await (await app.request('/api/admin/index/backfill', { method: 'POST', headers, body: '{}' })).json();
|
||||||
|
expect(step.processed).toBe(3);
|
||||||
|
const clean = await (await app.request('/api/admin/index/cleanup', { method: 'POST', headers, body: JSON.stringify({ days: 180 }) })).json();
|
||||||
|
expect(clean.removed).toBeGreaterThanOrEqual(1);
|
||||||
|
expect((await db.db.execute({ sql: "SELECT COUNT(*) AS n FROM video_meta WHERE id='OldOrphan01'" })).rows[0].n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup list verifies, previews, downloads, safely restores and exports a SQLite dump', async () => {
|
||||||
|
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||||
|
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'Before' }] }, force: true, source: 'user' });
|
||||||
|
const created = await (await app.request('/api/admin/backups', { method: 'POST', headers, body: '{}' })).json();
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'After' }] }, force: true, source: 'user' });
|
||||||
|
const list = await (await app.request('/api/admin/backups', { headers })).json();
|
||||||
|
expect(list.backups.find(item => item.date === created.date)).toMatchObject({ valid: true, notes: created.notes });
|
||||||
|
const preview = await (await app.request(`/api/admin/backups/${created.date}`, { headers })).json();
|
||||||
|
expect(preview.diff.changed).toBe(1);
|
||||||
|
const download = await app.request(`/api/admin/backups/${created.date}/download`, { headers });
|
||||||
|
expect(download.status).toBe(200);
|
||||||
|
const restored = await (await app.request(`/api/admin/backups/${created.date}/restore`, { method: 'POST', headers, body: JSON.stringify({ videoId: 'BackupSong01' }) })).json();
|
||||||
|
expect(restored.ok).toBe(true);
|
||||||
|
expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before');
|
||||||
|
expect(existsSync(join(backupDir, 'snapshots', restored.safetySnapshot))).toBe(true);
|
||||||
|
const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } });
|
||||||
|
expect(dump.status).toBe(200);
|
||||||
|
expect(await dump.text()).toContain('CREATE TABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lyrics search is indexed on save, filters chapter state, and rebuilds under admin auth', async () => {
|
||||||
|
const id = 'SearchSong01';
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: [id, JSON.stringify({ title: 'Amazing grace sample', channel: 'Choir' }), 'amazing grace sample choir', 1, Date.now()] });
|
||||||
|
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Amazing grace will lead me home' }] }, source: 'user', force: true });
|
||||||
|
expect((await app.request('/api/admin/lyrics/search?q=amazing')).status).toBe(401);
|
||||||
|
const headers = { 'x-admin': 'yes' };
|
||||||
|
let result = await (await app.request('/api/admin/lyrics/search?q=amazing+grace&hasChapters=no', { headers })).json();
|
||||||
|
expect(result.results[0]).toMatchObject({ videoId: id, title: 'Amazing grace sample', hasChapters: false });
|
||||||
|
expect(result.results[0].snippet).toContain('\u0001');
|
||||||
|
await db.saveNote({ videoId: id, kind: 'chapters', data: { items: [{ t: 1, title: 'Verse' }] }, source: 'user', force: true });
|
||||||
|
result = await (await app.request('/api/admin/lyrics/search?q=amazing&hasChapters=yes', { headers })).json();
|
||||||
|
expect(result.results[0].videoId).toBe(id);
|
||||||
|
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Bright morning star' }] }, source: 'user', force: true });
|
||||||
|
expect((await (await app.request('/api/admin/lyrics/search?q=amazing', { headers })).json()).total).toBe(0);
|
||||||
|
const rebuilt = await (await app.request('/api/admin/lyrics/search/rebuild', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: '{}' })).json();
|
||||||
|
expect(rebuilt.ok).toBe(true);
|
||||||
|
expect(rebuilt.indexed).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk lyrics jobs page the library, resume, clear with revision undo, export ZIP, and cancel', async () => {
|
||||||
|
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||||
|
expect((await app.request('/api/admin/lyrics/bulk/library')).status).toBe(401);
|
||||||
|
const library = await (await app.request('/api/admin/lyrics/bulk/library?q=untimed', { headers })).json();
|
||||||
|
expect(library.songs[0]).toMatchObject({ id: 'Untimely001', title: 'Untimed sample' });
|
||||||
|
const create = async body => (await app.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify(body) })).json();
|
||||||
|
|
||||||
|
const pull = (await create({ action: 'lrclib', ids: ['Untimely001'] })).job;
|
||||||
|
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/pause`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
|
||||||
|
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/resume`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
|
||||||
|
await tools.runBulkJob();
|
||||||
|
let current = await (await app.request(`/api/admin/lyrics/jobs/${pull.id}`, { headers: { 'x-admin': 'yes' } })).json();
|
||||||
|
expect(current.job).toMatchObject({ status: 'complete', state: { done: 1, results: [{ ok: true, id: 'Untimely001' }] } });
|
||||||
|
|
||||||
|
const clear = (await create({ action: 'clear', ids: ['Untimely001'], confirmClear: true })).job;
|
||||||
|
await tools.runBulkJob();
|
||||||
|
expect((await db.getNotes('Untimely001')).lyrics.data.lines).toEqual([]);
|
||||||
|
expect((await db.getNoteRev('Untimely001', 'lyrics', 1)).data.lines[0].text).toBe('A line');
|
||||||
|
|
||||||
|
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['ZipSong0001', JSON.stringify({ title: 'Zip Song' }), 'zip song', 1, Date.now()] });
|
||||||
|
await db.saveNote({ videoId: 'ZipSong0001', kind: 'lyrics', data: { lines: [{ t: 1.2, text: 'Saved line' }] }, source: 'user', force: true });
|
||||||
|
const exportJob = (await create({ action: 'export', ids: ['ZipSong0001'] })).job;
|
||||||
|
await tools.runBulkJob();
|
||||||
|
const archive = await app.request(`/api/admin/lyrics/jobs/${exportJob.id}/export`, { headers: { 'x-admin': 'yes' } });
|
||||||
|
expect(archive.status).toBe(200);
|
||||||
|
expect([...new Uint8Array(await archive.arrayBuffer())].slice(0, 2)).toEqual([0x50, 0x4b]);
|
||||||
|
|
||||||
|
const failingApp = new Hono();
|
||||||
|
const failingTools = registerAdminToolsRoutes(failingApp, { db, adminAuth: auth, backupDir: null, autoStartJobs: false });
|
||||||
|
const failingExport = (await (await failingApp.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify({ action: 'export', ids: ['ZipSong0001'] }) })).json()).job;
|
||||||
|
await failingTools.runBulkJob();
|
||||||
|
const failed = await (await failingApp.request(`/api/admin/lyrics/jobs/${failingExport.id}`, { headers: { 'x-admin': 'yes' } })).json();
|
||||||
|
expect(failed.job).toMatchObject({ status: 'failed', error: 'Export storage is unavailable.' });
|
||||||
|
|
||||||
|
const cancelled = (await create({ action: 'whisper', ids: ['Untimely001'] })).job;
|
||||||
|
await app.request(`/api/admin/lyrics/jobs/${cancelled.id}/cancel`, { method: 'POST', headers, body: '{}' });
|
||||||
|
current = await (await app.request(`/api/admin/lyrics/jobs/${cancelled.id}`, { headers: { 'x-admin': 'yes' } })).json();
|
||||||
|
expect(current.job.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
53
server/audit-log.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const asJson = value => {
|
||||||
|
try { return JSON.stringify(value ?? {}); } catch { return '{}'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function audit(db, actor, action, target = '', detail = {}) {
|
||||||
|
const client = typeof db?.execute === 'function' ? db : db?.db;
|
||||||
|
if (!client?.execute) throw new TypeError('audit database client is unavailable');
|
||||||
|
await client.execute({
|
||||||
|
sql: 'INSERT INTO audit_log (ts,actor,action,target,detail) VALUES (unixepoch(),?,?,?,?)',
|
||||||
|
args: [String(actor || 'admin').slice(0, 120), String(action).slice(0, 120), String(target || '').slice(0, 300), asJson(detail).slice(0, 8000)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuditRoutes(app, { db, adminAuth }) {
|
||||||
|
const client = typeof db?.execute === 'function' ? db : db?.db;
|
||||||
|
app.get('/api/admin/audit', adminAuth, async c => {
|
||||||
|
const format = c.req.query('format');
|
||||||
|
const limit = Math.max(1, Math.min(format ? 10000 : 200, Number(c.req.query('limit')) || (format ? 10000 : 50)));
|
||||||
|
const offset = format ? 0 : Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||||
|
const filters = [];
|
||||||
|
const args = [];
|
||||||
|
for (const key of ['actor', 'action']) {
|
||||||
|
const value = String(c.req.query(key) || '').trim().slice(0, 120);
|
||||||
|
if (value) { filters.push(`${key} LIKE ?`); args.push(`%${value.replace(/[\\%_]/g, '\\$&')}%`); }
|
||||||
|
}
|
||||||
|
const search = String(c.req.query('q') || '').trim().slice(0, 160);
|
||||||
|
if (search) { filters.push('(actor LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?)'); args.push(...Array(4).fill(`%${search.replace(/[\\%_]/g, '\\$&')}%`)); }
|
||||||
|
const from = Number(c.req.query('from'));
|
||||||
|
const to = Number(c.req.query('to'));
|
||||||
|
if (Number.isFinite(from) && from > 0) { filters.push('ts >= ?'); args.push(Math.floor(from)); }
|
||||||
|
if (Number.isFinite(to) && to > 0) { filters.push('ts <= ?'); args.push(Math.floor(to)); }
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
const [rows, count] = await Promise.all([
|
||||||
|
client.execute({ sql: `SELECT id,ts,actor,action,target,detail FROM audit_log ${where} ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] }),
|
||||||
|
client.execute({ sql: `SELECT COUNT(*) AS total FROM audit_log ${where}`, args }),
|
||||||
|
]);
|
||||||
|
const events = rows.rows.map(row => ({
|
||||||
|
id: Number(row.id), ts: Number(row.ts), actor: row.actor, action: row.action, target: row.target,
|
||||||
|
detail: (() => { try { return JSON.parse(row.detail || '{}'); } catch { return {}; } })(),
|
||||||
|
}));
|
||||||
|
if (format === 'json') {
|
||||||
|
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.json"');
|
||||||
|
return c.json({ events, total: Number(count.rows[0]?.total) || 0 });
|
||||||
|
}
|
||||||
|
if (format === 'csv') {
|
||||||
|
const quote = value => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||||
|
const body = ['id,ts,actor,action,target,detail', ...events.map(row => [row.id, new Date(row.ts * 1000).toISOString(), row.actor, row.action, row.target, JSON.stringify(row.detail)].map(quote).join(','))].join('\n');
|
||||||
|
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.csv"');
|
||||||
|
return c.text(body, 200, { 'Content-Type': 'text/csv; charset=utf-8' });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, events, total: Number(count.rows[0]?.total) || 0, limit, offset }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
}
|
||||||
35
server/audit-log.test.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-audit-test-'));
|
||||||
|
process.env.DB_PATH = join(root, 'audit.db');
|
||||||
|
const db = await import('./db.js');
|
||||||
|
const { audit, registerAuditRoutes } = await import('./audit-log.js');
|
||||||
|
const app = new Hono();
|
||||||
|
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await db.initDb();
|
||||||
|
registerAuditRoutes(app, { db, adminAuth: auth });
|
||||||
|
await audit(db, 'admin', 'notes.save.lyrics', 'video_1', { rev: 2 });
|
||||||
|
await audit(db, 'editor@example.test', 'report.resolve', '42', { batch: true });
|
||||||
|
});
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('admin audit log', () => {
|
||||||
|
test('is authenticated, filtered, paged and exports CSV/JSON', async () => {
|
||||||
|
expect((await app.request('/api/admin/audit')).status).toBe(401);
|
||||||
|
const headers = { 'x-admin': 'yes' };
|
||||||
|
const filtered = await (await app.request('/api/admin/audit?actor=editor&action=resolve', { headers })).json();
|
||||||
|
expect(filtered.total).toBe(1);
|
||||||
|
expect(filtered.events[0]).toMatchObject({ actor: 'editor@example.test', action: 'report.resolve', target: '42', detail: { batch: true } });
|
||||||
|
const csv = await app.request('/api/admin/audit?format=csv', { headers });
|
||||||
|
expect(csv.headers.get('content-type')).toContain('text/csv');
|
||||||
|
expect(await csv.text()).toContain('notes.save.lyrics');
|
||||||
|
const json = await (await app.request('/api/admin/audit?format=json', { headers })).json();
|
||||||
|
expect(json.events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
server/bun.lock
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "ytplayer-server",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.14.0",
|
||||||
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"hono": "^4.7.10",
|
||||||
|
"linkedom": "^0.18.13",
|
||||||
|
"nodemailer": "^6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
|
||||||
|
|
||||||
|
"@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="],
|
||||||
|
|
||||||
|
"@libsql/core": ["@libsql/core@0.14.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q=="],
|
||||||
|
|
||||||
|
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg=="],
|
||||||
|
|
||||||
|
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA=="],
|
||||||
|
|
||||||
|
"@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="],
|
||||||
|
|
||||||
|
"@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.3.1", "", {}, "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw=="],
|
||||||
|
|
||||||
|
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
|
||||||
|
|
||||||
|
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA=="],
|
||||||
|
|
||||||
|
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw=="],
|
||||||
|
|
||||||
|
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ=="],
|
||||||
|
|
||||||
|
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA=="],
|
||||||
|
|
||||||
|
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw=="],
|
||||||
|
|
||||||
|
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
|
||||||
|
|
||||||
|
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
|
||||||
|
|
||||||
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
|
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
|
||||||
|
|
||||||
|
"css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
|
||||||
|
|
||||||
|
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
||||||
|
|
||||||
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
|
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||||
|
|
||||||
|
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||||
|
|
||||||
|
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||||
|
|
||||||
|
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
|
"js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="],
|
||||||
|
|
||||||
|
"libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="],
|
||||||
|
|
||||||
|
"linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
||||||
|
"nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="],
|
||||||
|
|
||||||
|
"nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
|
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
||||||
|
|
||||||
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
|
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||||
|
|
||||||
|
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
|
||||||
|
|
||||||
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
|
|
||||||
|
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
|
"dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||||
|
|
||||||
|
"dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||||
|
|
||||||
|
"domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||||
|
|
||||||
|
"domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||||
|
|
||||||
|
"htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
|
"htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
222
server/db.js
@@ -20,6 +20,7 @@ const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db'
|
|||||||
export const db = createClient({
|
export const db = createClient({
|
||||||
url: 'file:' + DB_PATH,
|
url: 'file:' + DB_PATH,
|
||||||
});
|
});
|
||||||
|
let lyricsFtsAvailable = false;
|
||||||
|
|
||||||
// ---- Schema ----------------------------------------------------------------
|
// ---- Schema ----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -106,7 +107,8 @@ export async function initDb() {
|
|||||||
created_at INTEGER NOT NULL DEFAULT 0,
|
created_at INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||||
last_access INTEGER NOT NULL DEFAULT 0,
|
last_access INTEGER NOT NULL DEFAULT 0,
|
||||||
hits INTEGER NOT NULL DEFAULT 0
|
hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_lru
|
CREATE INDEX IF NOT EXISTS idx_media_lru
|
||||||
@@ -168,6 +170,15 @@ export async function initDb() {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_queue ON metadata_collections (status,created_at);
|
CREATE INDEX IF NOT EXISTS idx_collections_queue ON metadata_collections (status,created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deep_search_jobs (
|
||||||
|
id TEXT PRIMARY KEY, status TEXT NOT NULL, plan TEXT NOT NULL, options TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT '{}', error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deep_search_jobs ON deep_search_jobs(status,created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS deep_search_daily (
|
||||||
|
day TEXT PRIMARY KEY, queries INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lyric_transcriptions (
|
CREATE TABLE IF NOT EXISTS lyric_transcriptions (
|
||||||
id TEXT PRIMARY KEY, video_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
id TEXT PRIMARY KEY, video_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||||
base_rev INTEGER NOT NULL DEFAULT 0, result TEXT, error TEXT, stage TEXT NOT NULL DEFAULT 'queued',
|
base_rev INTEGER NOT NULL DEFAULT 0, result TEXT, error TEXT, stage TEXT NOT NULL DEFAULT 'queued',
|
||||||
@@ -223,7 +234,9 @@ export async function initDb() {
|
|||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
art TEXT, -- 'embedded' | 'file' | NULL
|
art TEXT, -- 'embedded' | 'file' | NULL
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
plays INTEGER NOT NULL DEFAULT 0
|
plays INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gen INTEGER NOT NULL DEFAULT 1,
|
||||||
|
art_gen INTEGER NOT NULL DEFAULT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
-- "This lyric line is wrong" reports from service mode. One open row per
|
-- "This lyric line is wrong" reports from service mode. One open row per
|
||||||
@@ -247,6 +260,13 @@ export async function initDb() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_flags_video ON lyric_flags (video_id, status);
|
CREATE INDEX IF NOT EXISTS idx_flags_video ON lyric_flags (video_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flags_status ON lyric_flags (status, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_flags_status ON lyric_flags (status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_lyrics_jobs (
|
||||||
|
id TEXT PRIMARY KEY, action TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
ids TEXT NOT NULL, state TEXT NOT NULL DEFAULT '{}', error TEXT,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_lyrics_jobs ON admin_lyrics_jobs(status,created_at);
|
||||||
|
|
||||||
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
|
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
|
||||||
-- token is stored; the plaintext is shown once when it is created.
|
-- token is stored; the plaintext is shown once when it is created.
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
@@ -256,12 +276,29 @@ export async function initDb() {
|
|||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
last_used_at INTEGER
|
last_used_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL DEFAULT '',
|
||||||
|
detail TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_actor_action ON audit_log (actor, action, ts DESC);
|
||||||
`);
|
`);
|
||||||
|
await migrateVideoMeta();
|
||||||
|
await initLyricsSearch();
|
||||||
|
try { await db.execute('ALTER TABLE media_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } catch { /* already migrated */ }
|
||||||
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
||||||
// is listed in everyone's search (admin uploads) or reachable only by link.
|
// is listed in everyone's search (admin uploads) or reachable only by link.
|
||||||
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
||||||
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
|
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
|
||||||
}
|
}
|
||||||
|
try { await db.execute('ALTER TABLE uploads ADD COLUMN gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
|
||||||
|
try { await db.execute('ALTER TABLE uploads ADD COLUMN art_gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS upload_owner_quotas (owner TEXT PRIMARY KEY,max_bytes INTEGER NOT NULL CHECK(max_bytes>=0),updated_at INTEGER NOT NULL)`);
|
||||||
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
|
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
|
||||||
// shared, so each song is measured once for everyone.
|
// shared, so each song is measured once for everyone.
|
||||||
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
|
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
|
||||||
@@ -279,11 +316,121 @@ export async function initDb() {
|
|||||||
)`);
|
)`);
|
||||||
// Optional profile protection: a password or generated key, stored only as a
|
// Optional profile protection: a password or generated key, stored only as a
|
||||||
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
|
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
|
||||||
for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) {
|
for (const col of ['secret_hash TEXT', 'secret_kind TEXT', 'recovery_email TEXT', 'recovery_discord TEXT', 'recovery_webhook TEXT']) {
|
||||||
try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ }
|
try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lyricsSearchContent(value) {
|
||||||
|
try {
|
||||||
|
const data = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
|
return [...(data?.lines || []).flatMap(line => [line?.text, line?.secondary, line?.phonetic]), ...(data?.tags || [])]
|
||||||
|
.filter(Boolean).join(' ').slice(0, 500_000);
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initLyricsSearch() {
|
||||||
|
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_state (id INTEGER PRIMARY KEY CHECK(id=1), indexed_at INTEGER NOT NULL)');
|
||||||
|
try {
|
||||||
|
await db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_search_fts USING fts5(video_id UNINDEXED, content, tokenize='unicode61 remove_diacritics 2')");
|
||||||
|
lyricsFtsAvailable = true;
|
||||||
|
} catch {
|
||||||
|
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_fts (video_id TEXT PRIMARY KEY, content TEXT NOT NULL)');
|
||||||
|
await db.execute('CREATE INDEX IF NOT EXISTS idx_lyrics_search_id ON lyrics_search_fts(video_id)');
|
||||||
|
lyricsFtsAvailable = false;
|
||||||
|
}
|
||||||
|
const state = await db.execute('SELECT id FROM lyrics_search_state WHERE id=1');
|
||||||
|
if (state.rows.length) return;
|
||||||
|
await db.execute('DELETE FROM lyrics_search_fts');
|
||||||
|
const rows = await db.execute("SELECT video_id,data FROM video_notes WHERE kind='lyrics'");
|
||||||
|
for (let i = 0; i < rows.rows.length; i += 100) {
|
||||||
|
const batch = rows.rows.slice(i, i + 100).map(row => ({
|
||||||
|
sql: lyricsFtsAvailable ? 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)' : 'INSERT OR REPLACE INTO lyrics_search_fts(video_id,content) VALUES(?,?)',
|
||||||
|
args: [row.video_id, lyricsSearchContent(row.data)],
|
||||||
|
}));
|
||||||
|
if (batch.length) await db.batch(batch, 'write');
|
||||||
|
}
|
||||||
|
await db.execute('INSERT INTO lyrics_search_state(id,indexed_at) VALUES(1,unixepoch())');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchNoteLyrics(query, { limit = 30, offset = 0, hasChapters = false, withoutChapters = false } = {}) {
|
||||||
|
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||||
|
if (!words.length) return [];
|
||||||
|
const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
||||||
|
const boundedOffset = Math.max(0, Math.min(1_000_000, Number(offset) || 0));
|
||||||
|
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
|
||||||
|
if (lyricsFtsAvailable) {
|
||||||
|
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
|
||||||
|
const result = await db.execute({ sql: `SELECT video_id, snippet(lyrics_search_fts,1,char(1),char(2),' … ',18) AS snippet
|
||||||
|
FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter} ORDER BY bm25(lyrics_search_fts) LIMIT ? OFFSET ?`, args: [match, boundedLimit, boundedOffset] });
|
||||||
|
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
|
||||||
|
}
|
||||||
|
const filters = words.map(() => 'content LIKE ?').join(' AND ');
|
||||||
|
const result = await db.execute({ sql: `SELECT video_id,substr(content,1,220) AS snippet FROM lyrics_search_fts WHERE ${filters} ORDER BY video_id LIMIT ? OFFSET ?`, args: [...words.map(word => `%${word}%`), boundedLimit, boundedOffset] });
|
||||||
|
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countNoteLyrics(query, { hasChapters = false, withoutChapters = false } = {}) {
|
||||||
|
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||||
|
if (!words.length) return 0;
|
||||||
|
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
|
||||||
|
if (lyricsFtsAvailable) {
|
||||||
|
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
|
||||||
|
return Number((await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter}`, args: [match] })).rows[0]?.n) || 0;
|
||||||
|
}
|
||||||
|
const filters = words.map(() => 'content LIKE ?').join(' AND ');
|
||||||
|
const result = await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE ${filters} ${chapterFilter}`, args: words.map(word => `%${word}%`) });
|
||||||
|
return Number(result.rows[0]?.n) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildLyricsSearch() {
|
||||||
|
await db.execute('DELETE FROM lyrics_search_fts');
|
||||||
|
await db.execute('DELETE FROM lyrics_search_state');
|
||||||
|
await initLyricsSearch();
|
||||||
|
return Number((await db.execute("SELECT COUNT(*) AS n FROM video_notes WHERE kind='lyrics'")).rows[0]?.n) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_ID = /^[\w-]{11}$/;
|
||||||
|
const folded = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
// These columns were added after video_meta shipped. Keep startup migration
|
||||||
|
// idempotent and page the backfill so a large catalogue never becomes one
|
||||||
|
// unbounded JS allocation.
|
||||||
|
export async function migrateVideoMeta({ pageSize = 500 } = {}) {
|
||||||
|
for (const [name, type] of [['url', 'TEXT'], ['channel_id', 'TEXT'], ['channel_url', 'TEXT'], ['published', 'TEXT'], ['lang', 'TEXT']]) {
|
||||||
|
try { await db.execute(`ALTER TABLE video_meta ADD COLUMN ${name} ${type}`); } catch { /* column already exists */ }
|
||||||
|
}
|
||||||
|
await db.execute('CREATE TABLE IF NOT EXISTS catalog_schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)');
|
||||||
|
const done = await db.execute({ sql: 'SELECT 1 FROM catalog_schema_migrations WHERE version=1' });
|
||||||
|
if (done.rows.length) return;
|
||||||
|
let cursor = '';
|
||||||
|
for (;;) {
|
||||||
|
const rows = (await db.execute({
|
||||||
|
sql: 'SELECT id,card,hay FROM video_meta WHERE id>? ORDER BY id LIMIT ?', args: [cursor, pageSize],
|
||||||
|
})).rows;
|
||||||
|
if (!rows.length) break;
|
||||||
|
const updates = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
cursor = row.id;
|
||||||
|
let card = {};
|
||||||
|
try { card = JSON.parse(row.card || '{}'); } catch { /* malformed legacy card */ }
|
||||||
|
const url = YOUTUBE_ID.test(row.id) ? `https://www.youtube.com/watch?v=${row.id}` : null;
|
||||||
|
const channelId = card.channelId || card.channel_id || null;
|
||||||
|
const channelUrl = card.channelUrl || card.channel_url || null;
|
||||||
|
const published = card.published || card.publishedAt || card.upload_date || null;
|
||||||
|
const lang = card.lang || card.language || null;
|
||||||
|
card = { ...card, url, channelId: channelId || '', channelUrl: channelUrl || '' };
|
||||||
|
const hay = folded([card.title, card.channel, channelId, card.description,
|
||||||
|
...(Array.isArray(card.tags) ? card.tags : []), ...(Array.isArray(card.categories) ? card.categories : [])].filter(Boolean).join(' '));
|
||||||
|
updates.push({ sql: 'UPDATE video_meta SET card=?,url=?,channel_id=?,channel_url=?,published=?,lang=?,hay=? WHERE id=?',
|
||||||
|
args: [JSON.stringify(card), url, channelId, channelUrl, published, lang, hay || folded(row.hay), row.id] });
|
||||||
|
}
|
||||||
|
await db.batch(updates, 'write');
|
||||||
|
if (rows.length < pageSize) break;
|
||||||
|
}
|
||||||
|
await db.execute({ sql: 'INSERT OR IGNORE INTO catalog_schema_migrations(version,applied_at) VALUES(1,?)', args: [Date.now()] });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Shared video notes (lyrics / chapters) ---------------------------------
|
// ---- Shared video notes (lyrics / chapters) ---------------------------------
|
||||||
|
|
||||||
function noteRow(r) {
|
function noteRow(r) {
|
||||||
@@ -331,6 +478,11 @@ export async function saveNote({ videoId, kind, data, baseRev, source, updatedBy
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
|
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||||
args: [videoId, kind, rev, json, source, updatedBy || null],
|
args: [videoId, kind, rev, json, source, updatedBy || null],
|
||||||
});
|
});
|
||||||
|
if (kind === 'lyrics') {
|
||||||
|
await tx.execute({ sql: 'DELETE FROM lyrics_search_fts WHERE video_id=?', args: [videoId] });
|
||||||
|
const content = lyricsSearchContent(data);
|
||||||
|
if (content) await tx.execute({ sql: 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)', args: [videoId, content] });
|
||||||
|
}
|
||||||
await tx.commit();
|
await tx.commit();
|
||||||
return { ok: true, rev };
|
return { ok: true, rev };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -429,11 +581,15 @@ export async function listFlagsForVideo(videoId, status = 'open') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Newest first across every video; status 'all' returns both.
|
// Newest first across every video; status 'all' returns both.
|
||||||
export async function listAllFlags({ status = 'open', limit = 200 } = {}) {
|
export async function listAllFlags({ status = 'open', limit = 200, olderThan = 0 } = {}) {
|
||||||
const where = status === 'all' ? '' : 'WHERE status = ?';
|
const filters = [];
|
||||||
|
const args = [];
|
||||||
|
if (status !== 'all') { filters.push('status = ?'); args.push(status); }
|
||||||
|
if (Number(olderThan) > 0) { filters.push('created_at <= ?'); args.push(Math.floor(Number(olderThan))); }
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
const r = await db.execute({
|
const r = await db.execute({
|
||||||
sql: `SELECT * FROM lyric_flags ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
sql: `SELECT * FROM lyric_flags ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
||||||
args: status === 'all' ? [limit] : [status, limit],
|
args: [...args, limit],
|
||||||
});
|
});
|
||||||
return r.rows.map(flagRow);
|
return r.rows.map(flagRow);
|
||||||
}
|
}
|
||||||
@@ -519,6 +675,7 @@ const MEDIA_COLS = new Set([
|
|||||||
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
|
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
|
||||||
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
|
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
|
||||||
'updated_at', 'last_access', 'hits', 'sha256',
|
'updated_at', 'last_access', 'hits', 'sha256',
|
||||||
|
'pinned',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function rowToObj(res, row) {
|
function rowToObj(res, row) {
|
||||||
@@ -556,7 +713,7 @@ export async function listMedia() {
|
|||||||
// Ready rows, least-recently-played first — the eviction order.
|
// Ready rows, least-recently-played first — the eviction order.
|
||||||
export async function listMediaLru() {
|
export async function listMediaLru() {
|
||||||
const r = await db.execute(
|
const r = await db.execute(
|
||||||
"SELECT video_id, size, last_access FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
|
"SELECT video_id, size, last_access, pinned FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
|
||||||
);
|
);
|
||||||
return r.rows.map((row) => rowToObj(r, row));
|
return r.rows.map((row) => rowToObj(r, row));
|
||||||
}
|
}
|
||||||
@@ -606,12 +763,13 @@ export async function createProfile(name, dataJson) {
|
|||||||
|
|
||||||
export async function getProfile(name) {
|
export async function getProfile(name) {
|
||||||
const r = await db.execute({
|
const r = await db.execute({
|
||||||
sql: 'SELECT data, updated_at, secret_hash, secret_kind FROM profiles WHERE name = ?',
|
sql: 'SELECT data, updated_at, secret_hash, secret_kind, recovery_email, recovery_discord, recovery_webhook FROM profiles WHERE name = ?',
|
||||||
args: [name],
|
args: [name],
|
||||||
});
|
});
|
||||||
const row = r.rows[0];
|
const row = r.rows[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null };
|
return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null,
|
||||||
|
recoveryEmail: row.recovery_email || null, recoveryDiscord: row.recovery_discord || null, recoveryWebhook: row.recovery_webhook || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setProfileSecret(name, hash, kind) {
|
export async function setProfileSecret(name, hash, kind) {
|
||||||
@@ -804,12 +962,13 @@ const uploadRow = (r) => ({
|
|||||||
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
|
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
|
||||||
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
|
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
|
||||||
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
|
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
|
||||||
|
gen: Number(r.gen) || 1, artGen: Number(r.art_gen) || 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createUpload(u) {
|
export async function createUpload(u) {
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
|
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, gen, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, unixepoch())`,
|
||||||
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
|
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
|
||||||
u.owner || null, u.listed === false ? 0 : 1],
|
u.owner || null, u.listed === false ? 0 : 1],
|
||||||
});
|
});
|
||||||
@@ -828,7 +987,7 @@ export async function sharedUploadUsage(owner) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setUploadArt(id, art) {
|
export async function setUploadArt(id, art) {
|
||||||
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
|
await db.execute({ sql: 'UPDATE uploads SET art = ?, art_gen = art_gen + 1 WHERE id = ?', args: [art, id] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUpload(id) {
|
export async function getUpload(id) {
|
||||||
@@ -836,6 +995,45 @@ export async function getUpload(id) {
|
|||||||
return r.rows[0] ? uploadRow(r.rows[0]) : null;
|
return r.rows[0] ? uploadRow(r.rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUploadMeta(id, patch = {}) {
|
||||||
|
const fields = ['title', 'artist', 'album', 'listed'].filter(k => patch[k] !== undefined);
|
||||||
|
if (!fields.length) return !!(await getUpload(id));
|
||||||
|
const values = fields.map(k => k === 'listed' ? (patch[k] ? 1 : 0) : String(patch[k] ?? '').slice(0, k === 'title' ? 300 : 200) || null);
|
||||||
|
const result = await db.execute({ sql: `UPDATE uploads SET ${fields.map(k => `${k}=?`).join(',')} WHERE id=?`, args: [...values, id] });
|
||||||
|
return !!result.rowsAffected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceUploadFile(id, upload) {
|
||||||
|
const result = await db.execute({ sql: `UPDATE uploads SET kind=?,title=?,artist=?,album=?,duration=?,ext=?,mime=?,size=?,art=?,gen=gen+1 WHERE id=?`,
|
||||||
|
args: [upload.kind, upload.title, upload.artist || null, upload.album || null, upload.duration || 0, upload.ext, upload.mime, upload.size || 0, upload.art || null, id] });
|
||||||
|
return !!result.rowsAffected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminUploads({ q = '', limit = 100, offset = 0 } = {}) {
|
||||||
|
const args = [], where = q ? "WHERE lower(title || ' ' || ifnull(artist,'') || ' ' || ifnull(album,'')) LIKE ?" : '';
|
||||||
|
if (q) args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, '\\$&')}%`);
|
||||||
|
const rows = await db.execute({ sql: `SELECT * FROM uploads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, args: [...args, Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
|
||||||
|
const count = await db.execute({ sql: `SELECT COUNT(*) AS n FROM uploads ${where}`, args });
|
||||||
|
const owners = await db.execute("SELECT ifnull(owner,'(admin)') AS owner,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes FROM uploads GROUP BY owner ORDER BY bytes DESC");
|
||||||
|
return { uploads: rows.rows.map(uploadRow), total: Number(count.rows[0]?.n) || 0, owners: owners.rows.map(r => ({ owner: r.owner, count: Number(r.count), bytes: Number(r.bytes) })) };
|
||||||
|
}
|
||||||
|
export async function listUploadFileNames() {
|
||||||
|
const r = await db.execute('SELECT id,ext FROM uploads');
|
||||||
|
return r.rows.flatMap(row => [`${row.id}.${row.ext}`, `${row.id}.art.jpg`]);
|
||||||
|
}
|
||||||
|
export async function listUploadQuotas() {
|
||||||
|
const r = await db.execute('SELECT owner,max_bytes,updated_at FROM upload_owner_quotas ORDER BY owner');
|
||||||
|
return r.rows.map(x => ({ owner: x.owner, maxBytes: Number(x.max_bytes), updatedAt: Number(x.updated_at) }));
|
||||||
|
}
|
||||||
|
export async function getUploadQuota(owner) {
|
||||||
|
const r = await db.execute({ sql: 'SELECT max_bytes FROM upload_owner_quotas WHERE owner=?', args: [owner] });
|
||||||
|
return r.rows[0] ? Number(r.rows[0].max_bytes) : null;
|
||||||
|
}
|
||||||
|
export async function setUploadQuota(owner, maxBytes) {
|
||||||
|
if (maxBytes == null || Number(maxBytes) <= 0) return db.execute({ sql: 'DELETE FROM upload_owner_quotas WHERE owner=?', args: [owner] });
|
||||||
|
return db.execute({ sql: 'INSERT INTO upload_owner_quotas(owner,max_bytes,updated_at) VALUES(?,?,unixepoch()) ON CONFLICT(owner) DO UPDATE SET max_bytes=excluded.max_bytes,updated_at=excluded.updated_at', args: [owner, Math.floor(Number(maxBytes))] });
|
||||||
|
}
|
||||||
|
|
||||||
// Newest first; `q` matches title/artist/album (case-insensitive).
|
// Newest first; `q` matches title/artist/album (case-insensitive).
|
||||||
// `listedOnly`: public search shows admin uploads, never device-shared ones.
|
// `listedOnly`: public search shows admin uploads, never device-shared ones.
|
||||||
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {
|
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {
|
||||||
|
|||||||
219
server/deep-search.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { db as defaultDb } from './db.js';
|
||||||
|
import * as catalog from './video-catalog.js';
|
||||||
|
import * as innertube from './innertube.js';
|
||||||
|
import * as searchCache from './search-cache.js';
|
||||||
|
import { audit } from './audit-log.js';
|
||||||
|
|
||||||
|
const MAX_SEEDS = 200, MAX_PLAN = 500, MAX_ERRORS = 50;
|
||||||
|
const clean = value => String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200);
|
||||||
|
const fold = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
|
||||||
|
const sleepDefault = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const DEFAULT_PRESETS = [
|
||||||
|
'worship', 'praise', 'hymns', 'gospel', 'pagsamba', 'papuri', 'mga awit ng papuri', 'ebanghelyo',
|
||||||
|
'dayeg', 'pagdayeg', 'mga alawiton', 'maayong balita', 'adoración', 'alabanza', 'himnos', 'evangelio',
|
||||||
|
'Hillsong Worship', 'Elevation Worship', 'Bethel Music', 'Planetshakers', 'Don Moen', 'Sandi Patty',
|
||||||
|
];
|
||||||
|
export const DEFAULT_MODIFIERS = ['lyrics', 'live', 'acoustic', 'instrumental', 'karaoke'];
|
||||||
|
|
||||||
|
export function expandQueryPlan({ seeds = [], modifiers = [], maxQueries = 80 } = {}) {
|
||||||
|
const seedList = (Array.isArray(seeds) ? seeds : String(seeds).split(/\r?\n/)).map(clean).filter(Boolean).slice(0, MAX_SEEDS);
|
||||||
|
const mods = (Array.isArray(modifiers) ? modifiers : String(modifiers).split(/[,\r\n]+/)).map(clean).filter(Boolean).slice(0, 30);
|
||||||
|
const cap = Math.max(1, Math.min(MAX_PLAN, Number(maxQueries) || 1));
|
||||||
|
const out = [], seen = new Set();
|
||||||
|
for (const seed of seedList) for (const modifier of ['', ...mods]) {
|
||||||
|
const query = clean(modifier ? `${seed} ${modifier}` : seed), key = fold(query);
|
||||||
|
if (key && !seen.has(key)) { seen.add(key); out.push(query); if (out.length >= cap) return out; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicJob(row, indexSize = 0) {
|
||||||
|
const state = parse(row.state);
|
||||||
|
const done = Number(state.queriesDone) || 0, total = JSON.parse(row.plan || '[]').length;
|
||||||
|
const elapsed = Math.max(0, Date.now() - Number(row.created_at));
|
||||||
|
const etaMs = done ? Math.max(0, Math.round(elapsed / done * (total - done))) : null;
|
||||||
|
return { id: row.id, status: row.status, plan: JSON.parse(row.plan || '[]'), options: parse(row.options),
|
||||||
|
queriesDone: done, queriesTotal: total, current: state.current || '', newVideos: Number(state.newVideos) || 0,
|
||||||
|
totalIndexSize: indexSize, errors: state.errors || [], log: state.log || [], etaMs,
|
||||||
|
createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), error: row.error || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeepSearchService({ db = defaultDb, searchDeep = innertube.searchDeep, related = innertube.related,
|
||||||
|
browseUploads = innertube.browseUploads, ingest = catalog.ingest, putSearch = searchCache.put,
|
||||||
|
sleep = sleepDefault, random = Math.random, now = Date.now, autoStart = true } = {}) {
|
||||||
|
// Accept the db.js module namespace as well as the client itself.
|
||||||
|
if (typeof db?.execute !== 'function' && typeof db?.db?.execute === 'function') db = db.db;
|
||||||
|
let worker = null;
|
||||||
|
let timer = null;
|
||||||
|
const log = (state, message) => { state.log ||= []; state.log.push({ at: now(), message: String(message).slice(0, 240) }); if (state.log.length > 100) state.log.splice(0, state.log.length - 100); };
|
||||||
|
async function indexSize() { return Number((await db.execute('SELECT COUNT(*) AS n FROM video_meta')).rows[0]?.n || 0); }
|
||||||
|
async function getJob(id) { return (await db.execute({ sql: 'SELECT * FROM deep_search_jobs WHERE id=?', args: [id] })).rows[0] || null; }
|
||||||
|
async function checkpoint(job, state, status = 'running', error = null) {
|
||||||
|
const r = await db.execute({ sql: "UPDATE deep_search_jobs SET state=?,status=?,error=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), status, error, now(), job.id] });
|
||||||
|
if (!r.rowsAffected) { const stop = new Error('Deep search paused or cancelled.'); stop.name = 'DeepSearchStopped'; throw stop; }
|
||||||
|
}
|
||||||
|
async function reserveDaily(cap) {
|
||||||
|
const day = new Date(now()).toISOString().slice(0, 10);
|
||||||
|
const r = await db.execute({ sql: `INSERT INTO deep_search_daily(day,queries) VALUES(?,1)
|
||||||
|
ON CONFLICT(day) DO UPDATE SET queries=queries+1 WHERE queries<? RETURNING queries`, args: [day, cap] });
|
||||||
|
return r.rows.length ? Number(r.rows[0].queries) : null;
|
||||||
|
}
|
||||||
|
async function run(job) {
|
||||||
|
const options = parse(job.options), plan = JSON.parse(job.plan || '[]');
|
||||||
|
const state = { queriesDone: 0, newVideos: 0, errors: [], log: [], current: '', relatedSeen: [], ...parse(job.state) };
|
||||||
|
async function saveCards(query, cards, cacheCards = cards) {
|
||||||
|
const unique = [...new Map((cards || []).filter(c => c?.id).map(c => [c.id, c])).values()];
|
||||||
|
if (unique.length) {
|
||||||
|
const validIds = unique.filter(c => /^[\w-]{11}$/.test(c.id)).map(c => c.id);
|
||||||
|
const existing = validIds.length ? (await db.execute({ sql: `SELECT id FROM video_meta WHERE id IN (${validIds.map(() => '?').join(',')})`, args: validIds })).rows : [];
|
||||||
|
const present = new Set(existing.map(row => row.id));
|
||||||
|
await ingest(unique, 'deep');
|
||||||
|
state.newVideos += validIds.filter(id => !present.has(id)).length;
|
||||||
|
}
|
||||||
|
await putSearch(query, (cacheCards || unique).slice(0, 200));
|
||||||
|
}
|
||||||
|
async function requireDailyBudget() {
|
||||||
|
const used = await reserveDaily(Math.max(1, Number(options.dailyCap) || 1000));
|
||||||
|
if (used !== null) return;
|
||||||
|
state.dailyPausedDay = new Date(now()).toISOString().slice(0, 10);
|
||||||
|
log(state, 'Daily query cap reached; job will resume tomorrow.');
|
||||||
|
await checkpoint(job, state, 'paused');
|
||||||
|
const stop = new Error('Daily cap reached.'); stop.name = 'DeepSearchStopped'; throw stop;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (let qi = state.queriesDone; qi < plan.length; qi++) {
|
||||||
|
const query = plan[qi];
|
||||||
|
if (state.current !== query) state.currentIds = [];
|
||||||
|
state.current = query; await checkpoint(job, state);
|
||||||
|
await requireDailyBudget();
|
||||||
|
await checkpoint(job, state);
|
||||||
|
let cards, lastError;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
cards = await searchDeep(query, { limit: Math.max(1, Math.min(200, Number(options.resultsPerQuery) || 50)), maxPages: 14, timeoutMs: 30000,
|
||||||
|
pageDelayMs: Math.max(0, Number(options.delayMs) || 0), jitterMs: Math.max(0, Number(options.jitterMs) || 0),
|
||||||
|
onPage: async pageCards => {
|
||||||
|
await checkpoint(job, state);
|
||||||
|
const saved = new Set(state.currentIds || []);
|
||||||
|
const fresh = pageCards.filter(card => !saved.has(card.id));
|
||||||
|
await saveCards(query, fresh, pageCards);
|
||||||
|
state.currentIds = pageCards.map(card => card.id);
|
||||||
|
await checkpoint(job, state);
|
||||||
|
} });
|
||||||
|
await checkpoint(job, state);
|
||||||
|
lastError = null; break;
|
||||||
|
} catch (error) { if (error.name === 'DeepSearchStopped') throw error; lastError = error; if (attempt < 2) await sleep(Math.min(60000, (Number(options.delayMs) || 10000) * 2 ** attempt + Math.floor(random() * 3000))); }
|
||||||
|
}
|
||||||
|
if (lastError) { state.errors.push({ query, error: String(lastError.message).slice(0, 200) }); if (state.errors.length > MAX_ERRORS) state.errors.shift(); log(state, `Search failed: ${query}`); }
|
||||||
|
else {
|
||||||
|
const saved = new Set(state.currentIds || []);
|
||||||
|
const remaining = (cards || []).filter(card => !saved.has(card.id));
|
||||||
|
await saveCards(query, remaining, cards || []);
|
||||||
|
state.currentIds = (cards || []).map(card => card.id);
|
||||||
|
const depth = Math.max(0, Math.min(2, Number(options.relatedDepth) || 0));
|
||||||
|
if (depth) {
|
||||||
|
let frontier = (cards || []).slice(0, Math.max(1, Math.min(10, Number(options.relatedTop) || 3))).map(c => ({ id: c.id, depth: 1 }));
|
||||||
|
for (let level = 1; level <= depth && frontier.length; level++) {
|
||||||
|
const next = [];
|
||||||
|
for (const item of frontier) {
|
||||||
|
if (state.relatedSeen.includes(item.id)) continue;
|
||||||
|
state.relatedSeen.push(item.id);
|
||||||
|
try {
|
||||||
|
await sleep(Math.max(0, Number(options.delayMs) || 10000) + Math.floor(random() * (Number(options.jitterMs) || 3000)));
|
||||||
|
await checkpoint(job, state);
|
||||||
|
await requireDailyBudget();
|
||||||
|
const recs = await related(item.id); await checkpoint(job, state);
|
||||||
|
await saveCards(`${query} related ${item.id}`, recs);
|
||||||
|
if (level < depth) next.push(...recs.slice(0, 2).map(c => ({ id: c.id, depth: level + 1 })));
|
||||||
|
} catch (error) { if (error.name === 'DeepSearchStopped') throw error; state.errors.push({ query: `related:${item.id}`, error: String(error.message).slice(0, 200) }); }
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(state, `Saved ${cards?.length || 0} results for “${query}”.`);
|
||||||
|
}
|
||||||
|
state.queriesDone = qi + 1; state.current = ''; state.currentIds = []; await checkpoint(job, state);
|
||||||
|
if (qi + 1 < plan.length) await sleep(Math.max(0, Number(options.delayMs) || 10000) + Math.floor(random() * (Number(options.jitterMs) || 3000)));
|
||||||
|
}
|
||||||
|
for (const source of [...(options.channels || []), ...(options.playlists || [])].slice(0, 50)) {
|
||||||
|
state.current = `browse ${source}`; await checkpoint(job, state);
|
||||||
|
try { await requireDailyBudget(); await checkpoint(job, state); const cards = await browseUploads(source, { limit: Math.max(1, Math.min(200, Number(options.resultsPerQuery) || 50)), pageDelayMs: options.delayMs, jitterMs: options.jitterMs }); await checkpoint(job, state); await saveCards(state.current, cards); log(state, `Browsed ${cards.length} videos from ${source}.`); }
|
||||||
|
catch (error) { if (error.name === 'DeepSearchStopped') throw error; state.errors.push({ query: state.current, error: String(error.message).slice(0, 200) }); }
|
||||||
|
await checkpoint(job, state);
|
||||||
|
}
|
||||||
|
state.current = ''; log(state, 'Deep search complete.'); await checkpoint(job, state, 'complete');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'DeepSearchStopped') {
|
||||||
|
state.errors.push({ query: state.current, error: String(error.message).slice(0, 200) });
|
||||||
|
await db.execute({ sql: "UPDATE deep_search_jobs SET state=?,status='failed',error=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), String(error.message).slice(0, 300), now(), job.id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function runNext() {
|
||||||
|
if (worker) return worker;
|
||||||
|
worker = (async () => {
|
||||||
|
// Recover jobs interrupted by process restart; the current query is safe to repeat.
|
||||||
|
await db.execute({ sql: "UPDATE deep_search_jobs SET status='queued',updated_at=? WHERE status='running'", args: [now()] });
|
||||||
|
const today = new Date(now()).toISOString().slice(0, 10);
|
||||||
|
const paused = (await db.execute("SELECT id,state FROM deep_search_jobs WHERE status='paused'")).rows;
|
||||||
|
for (const row of paused) if (parse(row.state).dailyPausedDay && parse(row.state).dailyPausedDay < today)
|
||||||
|
await db.execute({ sql: "UPDATE deep_search_jobs SET status='queued',updated_at=? WHERE id=? AND status='paused'", args: [now(), row.id] });
|
||||||
|
const job = (await db.execute({ sql: "SELECT * FROM deep_search_jobs WHERE status='queued' ORDER BY created_at LIMIT 1" })).rows[0];
|
||||||
|
if (job) {
|
||||||
|
await db.execute({ sql: "UPDATE deep_search_jobs SET status='running',updated_at=? WHERE id=? AND status='queued'", args: [now(), job.id] });
|
||||||
|
const current = await getJob(job.id); if (current?.status === 'running') await run(current);
|
||||||
|
}
|
||||||
|
return !!job;
|
||||||
|
})().finally(() => { worker = null; });
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
const kick = () => runNext().catch(error => console.error('[deep search]', error.message));
|
||||||
|
function start() { if (!timer) { timer = setInterval(kick, 15000); timer.unref?.(); } kick(); }
|
||||||
|
if (autoStart) start();
|
||||||
|
async function estimate(body) {
|
||||||
|
const plan = expandQueryPlan(body);
|
||||||
|
const browseSources = [...(Array.isArray(body?.channels) ? body.channels : []), ...(Array.isArray(body?.playlists) ? body.playlists : [])].map(clean).filter(Boolean).slice(0, 50).length;
|
||||||
|
const operations = plan.length + browseSources + (Number(body?.relatedDepth) > 0 ? Math.min(plan.length * (Number(body?.relatedTop) || 3) * Math.min(2, Number(body.relatedDepth)), 100) : 0);
|
||||||
|
const today = new Date(now()).toISOString().slice(0, 10);
|
||||||
|
const used = Number((await db.execute({ sql: 'SELECT queries FROM deep_search_daily WHERE day=?', args: [today] })).rows[0]?.queries || 0);
|
||||||
|
return { plan, queries: plan.length, browseSources, estimatedOperations: operations,
|
||||||
|
estimatedMinutes: Math.ceil(operations * Math.max(1, Number(body?.delaySeconds) || 10) / 60),
|
||||||
|
indexSize: await indexSize(), dailyUsed: used, dailyRemaining: Math.max(0, (Number(body?.dailyCap) || 1000) - used) };
|
||||||
|
}
|
||||||
|
function register(app, adminAuth) {
|
||||||
|
app.post('/api/admin/deep-search/estimate', adminAuth, async c => { let body = {}; try { body = await c.req.json(); } catch {} return c.json({ ok: true, ...(await estimate(body)) }); });
|
||||||
|
app.get('/api/admin/deep-search/jobs', adminAuth, async c => c.json({ ok: true, indexSize: await indexSize(), jobs: (await db.execute('SELECT * FROM deep_search_jobs ORDER BY created_at DESC LIMIT 30')).rows.map(row => publicJob(row)) }));
|
||||||
|
app.post('/api/admin/deep-search/start', adminAuth, async c => {
|
||||||
|
let body; try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'Invalid JSON.' }, 400); }
|
||||||
|
const plan = expandQueryPlan(body); if (!plan.length) return c.json({ ok: false, error: 'Add at least one seed term.' }, 400);
|
||||||
|
const active = (await db.execute("SELECT id FROM deep_search_jobs WHERE status IN ('queued','running','paused') LIMIT 1")).rows[0];
|
||||||
|
if (active) return c.json({ ok: false, error: 'A deep-search job is already active.' }, 409);
|
||||||
|
const options = { resultsPerQuery: Math.max(1, Math.min(200, Number(body.resultsPerQuery) || 50)), relatedDepth: Math.max(0, Math.min(2, Number(body.relatedDepth) || 0)), relatedTop: Math.max(1, Math.min(10, Number(body.relatedTop) || 3)), delayMs: Math.max(1000, Math.min(300000, (Number(body.delaySeconds) || 10) * 1000)), jitterMs: Math.max(0, Math.min(300000, (Number(body.jitterSeconds) || 3) * 1000)), dailyCap: Math.max(1, Math.min(10000, Number(body.dailyCap) || 1000)), channels: (Array.isArray(body.channels) ? body.channels : []).map(clean).filter(Boolean).slice(0, 25), playlists: (Array.isArray(body.playlists) ? body.playlists : []).map(clean).filter(Boolean).slice(0, 25) };
|
||||||
|
const id = randomUUID(), time = now();
|
||||||
|
await db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES(?,'queued',?,?,?,?,?)", args: [id, JSON.stringify(plan), JSON.stringify(options), JSON.stringify({ queriesDone: 0, newVideos: 0, errors: [], log: [] }), time, time] });
|
||||||
|
await audit(db, 'admin', 'deep-search.start', id, { queries: plan.length });
|
||||||
|
if (autoStart) kick(); return c.json({ ok: true, id }, 202);
|
||||||
|
});
|
||||||
|
for (const action of ['pause', 'resume', 'cancel']) app.post(`/api/admin/deep-search/:id/${action}`, adminAuth, async c => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
|
||||||
|
const target = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
|
||||||
|
const marks = allowed.map(() => '?').join(',');
|
||||||
|
const r = await db.execute({ sql: `UPDATE deep_search_jobs SET status=?,updated_at=? WHERE id=? AND status IN (${marks})`, args: [target, now(), id, ...allowed] });
|
||||||
|
if (r.rowsAffected) await audit(db, 'admin', `deep-search.${action}`, id);
|
||||||
|
if (action === 'resume' && r.rowsAffected && autoStart) kick();
|
||||||
|
return c.json({ ok: !!r.rowsAffected, status: r.rowsAffected ? target : undefined }, r.rowsAffected ? 200 : 409);
|
||||||
|
});
|
||||||
|
return { estimate, runNext, kick, start };
|
||||||
|
}
|
||||||
|
return { estimate, runNext, kick, start, register };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDeepSearchRoutes(app, options = {}) {
|
||||||
|
const service = createDeepSearchService(options);
|
||||||
|
service.register(app, options.adminAuth);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
122
server/deep-search.test.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { expect, test, beforeAll } from 'bun:test';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-deep-search-'));
|
||||||
|
process.env.DB_PATH = join(root, 'test.db');
|
||||||
|
const { initDb, db } = await import('./db.js');
|
||||||
|
const catalog = await import('./video-catalog.js');
|
||||||
|
const searchCache = await import('./search-cache.js');
|
||||||
|
const { createDeepSearchService, expandQueryPlan } = await import('./deep-search.js');
|
||||||
|
const auth = async (_c, next) => next();
|
||||||
|
const sample = { id: 'd33psrch001', title: 'Worship in Cebuano', channel: 'Hope Choir' };
|
||||||
|
|
||||||
|
beforeAll(async () => { await initDb(); });
|
||||||
|
|
||||||
|
function makeApp(service) {
|
||||||
|
const app = new Hono(); service.register(app, auth); return app;
|
||||||
|
}
|
||||||
|
async function request(app, path, method = 'GET', body) {
|
||||||
|
const response = await app.request(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
return { response, json: await response.json() };
|
||||||
|
}
|
||||||
|
const cleanDb = async () => {
|
||||||
|
await db.batch(['DELETE FROM deep_search_jobs', 'DELETE FROM deep_search_daily', 'DELETE FROM video_meta', 'DELETE FROM video_meta_sources', 'DELETE FROM search_cache']);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.serial('query plan expands modifiers, folds accents and deduplicates', () => {
|
||||||
|
expect(expandQueryPlan({ seeds: ['Adoración', 'adoracion', ' Gospel '], modifiers: ['live', 'LIVE'], maxQueries: 30 }))
|
||||||
|
.toEqual(['Adoración', 'Adoración live', 'Gospel', 'Gospel live']);
|
||||||
|
expect(expandQueryPlan({ seeds: ['one', 'two'], modifiers: ['live'], maxQueries: 2 })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('estimate and background job ingest to catalog, source and search cache', async () => {
|
||||||
|
await cleanDb();
|
||||||
|
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put,
|
||||||
|
searchDeep: async () => [sample], sleep: async () => {}, autoStart: false });
|
||||||
|
const app = makeApp(service);
|
||||||
|
const estimate = await request(app, '/api/admin/deep-search/estimate', 'POST', { seeds: ['worship'], modifiers: ['live','lyrics'], maxQueries: 4 });
|
||||||
|
expect(estimate.json.plan).toEqual(['worship', 'worship live', 'worship lyrics']);
|
||||||
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], modifiers: [], maxQueries: 1, delaySeconds: 1 });
|
||||||
|
expect(created.response.status).toBe(202);
|
||||||
|
await service.runNext();
|
||||||
|
const jobs = await request(app, '/api/admin/deep-search/jobs');
|
||||||
|
expect(jobs.json.jobs[0].status).toBe('complete');
|
||||||
|
expect(jobs.json.jobs[0].newVideos).toBe(1);
|
||||||
|
const row = (await db.execute({ sql: 'SELECT source FROM video_meta_sources WHERE video_id=?', args: [sample.id] })).rows[0];
|
||||||
|
expect(row.source).toBe('deep');
|
||||||
|
expect((await searchCache.get('worship')).results[0].id).toBe(sample.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('pause during upstream call does not ingest; resume continues from the persisted query', async () => {
|
||||||
|
await cleanDb(); let begin, release, calls = 0;
|
||||||
|
const began = new Promise(resolve => { begin = resolve; });
|
||||||
|
const gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put, autoStart: false,
|
||||||
|
sleep: async () => {}, searchDeep: async () => { calls++; if (calls === 1) { begin(); await gate; } return [sample]; } });
|
||||||
|
const app = makeApp(service);
|
||||||
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
||||||
|
const work = service.runNext(); await began;
|
||||||
|
await request(app, `/api/admin/deep-search/${created.json.id}/pause`, 'POST', {});
|
||||||
|
release(); await work;
|
||||||
|
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
|
||||||
|
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
|
||||||
|
await request(app, `/api/admin/deep-search/${created.json.id}/resume`, 'POST', {});
|
||||||
|
await service.runNext();
|
||||||
|
jobs = await request(app, '/api/admin/deep-search/jobs');
|
||||||
|
expect(jobs.json.jobs[0].status).toBe('complete'); expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('cancelled in-flight job does not write its late result', async () => {
|
||||||
|
await cleanDb(); let begin, release;
|
||||||
|
const began = new Promise(resolve => { begin = resolve; }), gate = new Promise(resolve => { release = resolve; });
|
||||||
|
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {},
|
||||||
|
searchDeep: async () => { begin(); await gate; return [sample]; }, ingest: catalog.ingest, putSearch: searchCache.put });
|
||||||
|
const app = makeApp(service);
|
||||||
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
||||||
|
const work = service.runNext(); await began;
|
||||||
|
await request(app, `/api/admin/deep-search/${created.json.id}/cancel`, 'POST', {}); release(); await work;
|
||||||
|
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
|
||||||
|
const jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('page checkpoint survives a retry without double-counting its videos', async () => {
|
||||||
|
await cleanDb(); let calls = 0;
|
||||||
|
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {}, ingest: catalog.ingest, putSearch: searchCache.put,
|
||||||
|
searchDeep: async (_query, { onPage }) => {
|
||||||
|
calls++;
|
||||||
|
await onPage([sample]);
|
||||||
|
if (calls === 1) throw Error('connection reset after first page');
|
||||||
|
return [sample];
|
||||||
|
} });
|
||||||
|
const app = makeApp(service);
|
||||||
|
await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
||||||
|
await service.runNext();
|
||||||
|
const row = (await db.execute({ sql: 'SELECT seen FROM video_meta WHERE id=?', args: [sample.id] })).rows[0];
|
||||||
|
expect(Number(row.seen)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('daily cap pauses and worker resumes it on the next UTC day', async () => {
|
||||||
|
await cleanDb(); let time = Date.parse('2026-10-14T23:59:00Z');
|
||||||
|
const service = createDeepSearchService({ db, now: () => time, autoStart: false, sleep: async () => {},
|
||||||
|
searchDeep: async query => [{ ...sample, id: query === 'worship' ? 'd33psrch001' : 'd33psrch002' }],
|
||||||
|
ingest: catalog.ingest, putSearch: searchCache.put });
|
||||||
|
const app = makeApp(service);
|
||||||
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship','praise'], maxQueries: 2, delaySeconds: 1, dailyCap: 1 });
|
||||||
|
await service.runNext();
|
||||||
|
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
|
||||||
|
time += 120000; await service.runNext(); jobs = await request(app, '/api/admin/deep-search/jobs');
|
||||||
|
expect(jobs.json.jobs[0].status).toBe('complete'); expect(jobs.json.jobs[0].queriesDone).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('only one queued/running job is accepted', async () => {
|
||||||
|
await cleanDb();
|
||||||
|
const service = createDeepSearchService({ db, autoStart: false }); const app = makeApp(service);
|
||||||
|
const body = { seeds: ['worship'], maxQueries: 1 };
|
||||||
|
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(202);
|
||||||
|
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('cleanup test database', () => { rmSync(root, { recursive: true, force: true }); });
|
||||||
@@ -20,6 +20,7 @@ function cardsFromSections(sections) {
|
|||||||
const path = be.canonicalBaseUrl || (be.browseId ? `/channel/${be.browseId}` : '');
|
const path = be.canonicalBaseUrl || (be.browseId ? `/channel/${be.browseId}` : '');
|
||||||
out.push({
|
out.push({
|
||||||
id: v.videoId,
|
id: v.videoId,
|
||||||
|
url: `https://www.youtube.com/watch?v=${v.videoId}`,
|
||||||
title: v.title?.runs?.map((r) => r.text).join('') || '(untitled)',
|
title: v.title?.runs?.map((r) => r.text).join('') || '(untitled)',
|
||||||
channel: owner.text || '',
|
channel: owner.text || '',
|
||||||
channelId: be.browseId || '',
|
channelId: be.browseId || '',
|
||||||
@@ -78,7 +79,7 @@ export async function search(q, { fetchImpl = fetch, timeoutMs = 6000 } = {}) {
|
|||||||
// are a chain, so they are fetched one after another; every page is optional —
|
// are a chain, so they are fetched one after another; every page is optional —
|
||||||
// a failure part-way returns what was gathered. `onPage(cards)` lets a caller
|
// a failure part-way returns what was gathered. `onPage(cards)` lets a caller
|
||||||
// save progress.
|
// save progress.
|
||||||
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage } = {}) {
|
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage, pageDelayMs = 0, jitterMs = 0 } = {}) {
|
||||||
const post = async (body) => {
|
const post = async (body) => {
|
||||||
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
|
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -92,19 +93,92 @@ export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs
|
|||||||
const all = [];
|
const all = [];
|
||||||
const add = (cards) => { for (const c of cards) if (!seen.has(c.id)) { seen.add(c.id); all.push(c); } };
|
const add = (cards) => { for (const c of cards) if (!seen.has(c.id)) { seen.add(c.id); all.push(c); } };
|
||||||
add(first.cards);
|
add(first.cards);
|
||||||
if (onPage) onPage(all.slice());
|
if (onPage) await onPage(all.slice());
|
||||||
let next = first.next;
|
let next = first.next;
|
||||||
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
|
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
|
||||||
|
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
|
||||||
let page;
|
let page;
|
||||||
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
|
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
|
||||||
if (!page.cards.length) break;
|
if (!page.cards.length) break;
|
||||||
add(page.cards);
|
add(page.cards);
|
||||||
if (onPage) onPage(all.slice());
|
if (onPage) await onPage(all.slice());
|
||||||
next = page.next;
|
next = page.next;
|
||||||
}
|
}
|
||||||
return all.slice(0, limit);
|
return all.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function browseToken(value) {
|
||||||
|
if (!value || typeof value !== 'object') return '';
|
||||||
|
const token = value.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
||||||
|
if (token) return token;
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
if (Array.isArray(child)) { for (const item of child) { const found = browseToken(item); if (found) return found; } }
|
||||||
|
else { const found = browseToken(child); if (found) return found; }
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBrowseVideos(json) {
|
||||||
|
const cards = new Map();
|
||||||
|
function visit(value) {
|
||||||
|
if (!value || typeof value !== 'object') return;
|
||||||
|
const video = value.videoRenderer || value.gridVideoRenderer || value.richItemRenderer?.content?.videoRenderer;
|
||||||
|
const id = video?.videoId;
|
||||||
|
const title = video?.title?.runs?.map(run => run.text).join('') || video?.title?.simpleText || '';
|
||||||
|
if (id && /^[\w-]{11}$/.test(id) && title) {
|
||||||
|
const owner = video.ownerText?.runs?.[0] || video.shortBylineText?.runs?.[0] || {};
|
||||||
|
const browse = owner.navigationEndpoint?.browseEndpoint || {};
|
||||||
|
cards.set(id, { id, url: `https://www.youtube.com/watch?v=${id}`, title, channel: owner.text || '',
|
||||||
|
channelId: browse.browseId || '', channelUrl: browse.canonicalBaseUrl ? `https://www.youtube.com${browse.canonicalBaseUrl}` : '',
|
||||||
|
duration: lengthToSeconds(video.lengthText?.simpleText), thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) if (Array.isArray(child)) child.forEach(visit); else visit(child);
|
||||||
|
}
|
||||||
|
visit(json);
|
||||||
|
return [...cards.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube browse continuation for channel uploads and playlists. Channel IDs
|
||||||
|
// are translated to their uploads playlist (UU…), while playlist URLs use VL.
|
||||||
|
export async function browseUploads(source, { limit = 200, fetchImpl = fetch, timeoutMs = 8000, maxPages = 12, pageDelayMs = 0, jitterMs = 0 } = {}) {
|
||||||
|
let id = String(source || '').trim();
|
||||||
|
let handle = '';
|
||||||
|
try {
|
||||||
|
const url = new URL(id);
|
||||||
|
id = url.searchParams.get('list') || url.pathname.match(/\/channel\/([^/]+)/)?.[1] || id;
|
||||||
|
handle = url.pathname.match(/\/@([^/]+)/)?.[1] || '';
|
||||||
|
} catch { /* raw channel or playlist id */ }
|
||||||
|
if (handle) {
|
||||||
|
const response = await fetchImpl(`https://www.youtube.com/@${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
if (!response.ok) throw new Error(`channel lookup: HTTP ${response.status}`);
|
||||||
|
const html = await response.text();
|
||||||
|
id = html.match(/"browseId"\s*:\s*"(UC[\w-]{22})"/)?.[1] || '';
|
||||||
|
}
|
||||||
|
let browseId;
|
||||||
|
if (/^UC[\w-]{22}$/.test(id)) browseId = `UU${id.slice(2)}`;
|
||||||
|
else if (/^(?:PL|UU|LL|FL|OL)[\w-]{10,}$/.test(id)) browseId = id.startsWith('UU') || id.startsWith('LL') || id.startsWith('FL') || id.startsWith('OL') ? id : `VL${id}`;
|
||||||
|
else throw new Error('Expected a YouTube channel ID or playlist ID/URL.');
|
||||||
|
const post = async body => {
|
||||||
|
const response = await fetchImpl('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ context: { client: CLIENT }, ...body }), signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`innertube browse: HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
let page = await post({ browseId });
|
||||||
|
const seen = new Set(), cards = [];
|
||||||
|
for (let i = 0; i < maxPages && cards.length < limit; i++) {
|
||||||
|
for (const card of parseBrowseVideos(page)) if (!seen.has(card.id)) { seen.add(card.id); cards.push(card); }
|
||||||
|
const token = browseToken(page);
|
||||||
|
if (!token || cards.length >= limit) break;
|
||||||
|
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
|
||||||
|
page = await post({ continuation: token });
|
||||||
|
}
|
||||||
|
return cards.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
// Watch-next recommendations use compact renderers or the newer lockup model.
|
// Watch-next recommendations use compact renderers or the newer lockup model.
|
||||||
// Traverse only the recommendation contents, never player configuration/ads.
|
// Traverse only the recommendation contents, never player configuration/ads.
|
||||||
export function parseRelated(json) {
|
export function parseRelated(json) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test('parses video renderers into slim cards', () => {
|
|||||||
const r = parseSearch(fixture);
|
const r = parseSearch(fixture);
|
||||||
expect(r.length).toBe(4);
|
expect(r.length).toBe(4);
|
||||||
expect(r[0]).toEqual({
|
expect(r[0]).toEqual({
|
||||||
id: 'nQWFzMvCfLE', title: 'What A Beautiful Name - Hillsong Worship', channel: 'Hillsong Worship',
|
id: 'nQWFzMvCfLE', url: 'https://www.youtube.com/watch?v=nQWFzMvCfLE', title: 'What A Beautiful Name - Hillsong Worship', channel: 'Hillsong Worship',
|
||||||
channelId: 'UC4q12NoPNySbVqwpw4iO5Vg', channelUrl: 'https://www.youtube.com/channel/UC4q12NoPNySbVqwpw4iO5Vg',
|
channelId: 'UC4q12NoPNySbVqwpw4iO5Vg', channelUrl: 'https://www.youtube.com/channel/UC4q12NoPNySbVqwpw4iO5Vg',
|
||||||
duration: 343, thumbnail: 'https://i.ytimg.com/vi/nQWFzMvCfLE/mqdefault.jpg',
|
duration: 343, thumbnail: 'https://i.ytimg.com/vi/nQWFzMvCfLE/mqdefault.jpg',
|
||||||
});
|
});
|
||||||
@@ -60,3 +60,18 @@ test('related HTTP failures and unsupported shapes throw for search fallback', a
|
|||||||
expect(() => parseRelated({ error: {} })).toThrow('response shape');
|
expect(() => parseRelated({ error: {} })).toThrow('response shape');
|
||||||
await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
|
await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('channel and playlist browse parses uploads and resolves handle URLs', async () => {
|
||||||
|
const { browseUploads, parseBrowseVideos } = await import('./innertube.js');
|
||||||
|
const renderer = { videoRenderer: { videoId: 'abcdefghijk', title: { simpleText: 'Upload' }, ownerText: { runs: [{ text: 'Choir', navigationEndpoint: { browseEndpoint: { browseId: 'UC1234567890123456789012', canonicalBaseUrl: '/@choir' } } }] }, lengthText: { simpleText: '2:10' } } };
|
||||||
|
expect(parseBrowseVideos({ contents: [{ gridVideoRenderer: { videoId: 'abcdefghijk', title: { simpleText: 'Upload' } } }] })[0].title).toBe('Upload');
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, init = {}) => {
|
||||||
|
calls.push([String(url), init.body && JSON.parse(init.body)]);
|
||||||
|
if (String(url).includes('/@choir')) return new Response('"browseId":"UC1234567890123456789012"');
|
||||||
|
return Response.json({ contents: [{ gridVideoRenderer: renderer.videoRenderer }] });
|
||||||
|
};
|
||||||
|
const result = await browseUploads('https://www.youtube.com/@choir', { fetchImpl, limit: 5 });
|
||||||
|
expect(result[0].id).toBe('abcdefghijk');
|
||||||
|
expect(calls[1][1].browseId).toBe('UU1234567890123456789012');
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ export const LOW = 1; // auto-cache after a play
|
|||||||
|
|
||||||
// Compression-lane revision. `optimized` stores the revision a copy was last
|
// Compression-lane revision. `optimized` stores the revision a copy was last
|
||||||
// evaluated with; bumping this re-queues every cached copy once (rev 1 was
|
// evaluated with; bumping this re-queues every cached copy once (rev 1 was
|
||||||
// x264, which never beat YouTube's own encode; rev 2 is HEVC).
|
// x264, which never beat YouTube's own encode; rev 2 is HEVC; rev 3 adds the
|
||||||
export const OPT_REV = 2;
|
// still-picture lane, so rev-2 copies are re-checked for it once).
|
||||||
|
export const OPT_REV = 3;
|
||||||
|
|
||||||
const ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
|
const ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
|
||||||
export const isMediaId = (id) => typeof id === 'string' && ID_RE.test(id);
|
export const isMediaId = (id) => typeof id === 'string' && ID_RE.test(id);
|
||||||
@@ -45,6 +46,13 @@ const EST_BYTES_PER_SEC = 250 * 1024; // ~2 Mbps: pre-compression 720p + 128k
|
|||||||
const MIN_SAVING = 0.15; // keep a re-encode only if ≥15% smaller
|
const MIN_SAVING = 0.15; // keep a re-encode only if ≥15% smaller
|
||||||
const SAMPLE_SECONDS = 10; // per sample window (two windows)
|
const SAMPLE_SECONDS = 10; // per sample window (two windows)
|
||||||
const SAMPLE_MIN_DURATION = 60; // shorter videos are cheap enough to encode whole
|
const SAMPLE_MIN_DURATION = 60; // shorter videos are cheap enough to encode whole
|
||||||
|
// Still-picture lane: album-art uploads ("Provided to YouTube", fan uploads)
|
||||||
|
// show one image for the whole song. Measured: largest keyframe-to-keyframe
|
||||||
|
// scene score 0.003 on a Topic track vs 0.99 on a lyric video, so 0.01 is a
|
||||||
|
// safe cut. One frame looped at 1 fps costs ~16 kbps instead of ~335-1750.
|
||||||
|
const STILL_MAX_SCENE = 0.01;
|
||||||
|
const STILL_MIN_KEYFRAMES = 3;
|
||||||
|
const STILL_MIN_DURATION = 30;
|
||||||
// x265 tuning from the codec benchmark: stronger adaptive quantisation and
|
// x265 tuning from the codec benchmark: stronger adaptive quantisation and
|
||||||
// psy settings, no SAO blur, lighter deblock, longer lookahead.
|
// psy settings, no SAO blur, lighter deblock, longer lookahead.
|
||||||
const X265_TUNING = 'aq-mode=3:aq-strength=0.8:psy-rd=1.5:psy-rdoq=1.0:no-sao=1:deblock=-1,-1:'
|
const X265_TUNING = 'aq-mode=3:aq-strength=0.8:psy-rd=1.5:psy-rdoq=1.0:no-sao=1:deblock=-1,-1:'
|
||||||
@@ -158,7 +166,7 @@ function metaFromInfo(info, duration) {
|
|||||||
// getInfo async (videoId) => yt-dlp -J info — duration, live flags, meta
|
// getInfo async (videoId) => yt-dlp -J info — duration, live flags, meta
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
export function createMediaCache({
|
export function createMediaCache({
|
||||||
dir, db, download, getInfo,
|
dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`,
|
||||||
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
|
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
|
||||||
maxBytes = 10 * 1024 ** 3,
|
maxBytes = 10 * 1024 ** 3,
|
||||||
minFreeBytes = 5 * 1024 ** 3,
|
minFreeBytes = 5 * 1024 ** 3,
|
||||||
@@ -242,7 +250,7 @@ export function createMediaCache({
|
|||||||
const order = db.listMediaEvictionOrder ? await db.listMediaEvictionOrder() : await db.listMediaLru();
|
const order = db.listMediaEvictionOrder ? await db.listMediaEvictionOrder() : await db.listMediaLru();
|
||||||
for (const r of order) {
|
for (const r of order) {
|
||||||
if (bytes + est <= maxBytes) break;
|
if (bytes + est <= maxBytes) break;
|
||||||
if (r.video_id === id || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
|
if (r.video_id === id || r.pinned || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
|
||||||
await evict(r.video_id, 'budget');
|
await evict(r.video_id, 'budget');
|
||||||
bytes -= r.size || 0;
|
bytes -= r.size || 0;
|
||||||
}
|
}
|
||||||
@@ -314,7 +322,9 @@ export function createMediaCache({
|
|||||||
await job.persisted;
|
await job.persisted;
|
||||||
try {
|
try {
|
||||||
await setStatus(job, 'downloading');
|
await setStatus(job, 'downloading');
|
||||||
const info = await getInfo(id);
|
const sourceUrl = await getSourceUrl(id);
|
||||||
|
if (!sourceUrl || !/^https:\/\/www\.youtube\.com\/watch\?v=[\w-]{11}$/.test(sourceUrl)) throw new MediaSkip('source URL is unavailable');
|
||||||
|
const info = await getInfo(id, sourceUrl);
|
||||||
const live = info.is_live || ['is_live', 'post_live', 'is_upcoming'].includes(info.live_status);
|
const live = info.is_live || ['is_live', 'post_live', 'is_upcoming'].includes(info.live_status);
|
||||||
if (live) throw new MediaSkip('live streams are not cached');
|
if (live) throw new MediaSkip('live streams are not cached');
|
||||||
const expected = typeof info.duration === 'number' ? info.duration : 0;
|
const expected = typeof info.duration === 'number' ? info.duration : 0;
|
||||||
@@ -324,7 +334,7 @@ export function createMediaCache({
|
|||||||
}
|
}
|
||||||
await makeRoom(id, Math.max(expected, 60) * EST_BYTES_PER_SEC);
|
await makeRoom(id, Math.max(expected, 60) * EST_BYTES_PER_SEC);
|
||||||
|
|
||||||
const raw = await download(id, `${base}.dl.mp4`);
|
const raw = await download(id, `${base}.dl.mp4`, sourceUrl);
|
||||||
await setStatus(job, 'validating');
|
await setStatus(job, 'validating');
|
||||||
|
|
||||||
// Normalise: first video + first audio, moov atom up front so the copy
|
// Normalise: first video + first audio, moov atom up front so the copy
|
||||||
@@ -418,6 +428,10 @@ export function createMediaCache({
|
|||||||
};
|
};
|
||||||
// Very long videos would tie the lane up for hours on 2 niced threads.
|
// Very long videos would tie the lane up for hours on 2 niced threads.
|
||||||
if (transcode.maxSeconds && row.duration > transcode.maxSeconds) { await markDone(); return; }
|
if (transcode.maxSeconds && row.duration > transcode.maxSeconds) { await markDone(); return; }
|
||||||
|
const still = row.duration >= STILL_MIN_DURATION && await isStill(src).catch(() => false);
|
||||||
|
if (still) return optimizeStill(id, row, src, markDone);
|
||||||
|
// Rev-2 copies already went through the HEVC lane; only the still check was new.
|
||||||
|
if ((row.optimized || 0) >= 2) { await markDone(); return; }
|
||||||
const hevc = transcode.codec !== 'h264';
|
const hevc = transcode.codec !== 'h264';
|
||||||
const prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`;
|
const prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`;
|
||||||
const out = join(tmpDir, `${prefix}.mp4`);
|
const out = join(tmpDir, `${prefix}.mp4`);
|
||||||
@@ -453,27 +467,7 @@ export function createMediaCache({
|
|||||||
if (r.code !== 0) throw new Error(`${hevc ? 'x265' : 'x264'} encode failed: ` + tail(r.stderr));
|
if (r.code !== 0) throw new Error(`${hevc ? 'x265' : 'x264'} encode failed: ` + tail(r.stderr));
|
||||||
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] });
|
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] });
|
||||||
|
|
||||||
const cur = await db.getMedia(id);
|
await commitOptimized(id, row, src, out, probe, probe.vcodec);
|
||||||
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
|
|
||||||
const oldSize = sizeOf(src);
|
|
||||||
if (probe.size > oldSize * (1 - MIN_SAVING)) {
|
|
||||||
await db.upsertMedia(id, { optimized: OPT_REV });
|
|
||||||
log.info?.(`[media] kept original ${id}: ${probe.vcodec} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sha256 = await hashFile(out);
|
|
||||||
const gen = row.gen + 1;
|
|
||||||
renameSync(out, fileFor(id, gen));
|
|
||||||
const oldAudio = fileFor(id, row.gen, 'm4a');
|
|
||||||
const newAudio = fileFor(id, gen, 'm4a');
|
|
||||||
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
|
|
||||||
await db.upsertMedia(id, {
|
|
||||||
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
|
|
||||||
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
|
|
||||||
});
|
|
||||||
notifyReady(id, gen, sha256, probe, cur.meta);
|
|
||||||
removeFiles(id, gen, OLD_GEN_GRACE_MS);
|
|
||||||
log.info?.(`[media] optimized ${id}: ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await markDone().catch(() => {}); // don't retry a source the encoder can't improve
|
await markDone().catch(() => {}); // don't retry a source the encoder can't improve
|
||||||
throw err;
|
throw err;
|
||||||
@@ -485,6 +479,73 @@ export function createMediaCache({
|
|||||||
// Encode two short windows (at 25% and 60% of the video) and compare them
|
// Encode two short windows (at 25% and 60% of the video) and compare them
|
||||||
// with the source's own video bytes over exactly the same timestamps.
|
// with the source's own video bytes over exactly the same timestamps.
|
||||||
// Returns encoded/source (below 1 = smaller).
|
// Returns encoded/source (below 1 = smaller).
|
||||||
|
async function commitOptimized(id, row, src, out, probe, label) {
|
||||||
|
const cur = await db.getMedia(id);
|
||||||
|
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
|
||||||
|
const oldSize = sizeOf(src);
|
||||||
|
if (probe.size > oldSize * (1 - MIN_SAVING)) {
|
||||||
|
await db.upsertMedia(id, { optimized: OPT_REV });
|
||||||
|
log.info?.(`[media] kept original ${id}: ${label} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sha256 = await hashFile(out);
|
||||||
|
const gen = row.gen + 1;
|
||||||
|
renameSync(out, fileFor(id, gen));
|
||||||
|
const oldAudio = fileFor(id, row.gen, 'm4a');
|
||||||
|
const newAudio = fileFor(id, gen, 'm4a');
|
||||||
|
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
|
||||||
|
await db.upsertMedia(id, {
|
||||||
|
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
|
||||||
|
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
|
||||||
|
});
|
||||||
|
notifyReady(id, gen, sha256, probe, cur.meta);
|
||||||
|
removeFiles(id, gen, OLD_GEN_GRACE_MS);
|
||||||
|
log.info?.(`[media] optimized ${id} (${label}): ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyframes only: ~1 s for a 5-minute video. Every keyframe-to-keyframe
|
||||||
|
// scene score must stay under STILL_MAX_SCENE.
|
||||||
|
async function isStill(src) {
|
||||||
|
const r = await run(ffmpeg, ['-hide_banner', '-nostdin', '-skip_frame', 'nokey', '-i', src, '-an',
|
||||||
|
'-vf', "scale=160:-2,select='gte(scene\\,0)',metadata=print:key=lavfi.scene_score", '-f', 'null', '-']);
|
||||||
|
if (r.code !== 0) return false;
|
||||||
|
const scores = [...String(r.stderr).matchAll(/scene_score=([\d.]+)/g)].map((m) => Number(m[1]));
|
||||||
|
return scores.length + 1 >= STILL_MIN_KEYFRAMES && scores.every((x) => x <= STILL_MAX_SCENE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One frame from the middle, looped at 1 fps, audio copied. H.264 so every
|
||||||
|
// client plays it from the cache (HEVC is only served to clients that ask).
|
||||||
|
async function optimizeStill(id, row, src, markDone) {
|
||||||
|
const prefix = `${id}-${now()}-still`;
|
||||||
|
const frame = join(tmpDir, `${prefix}.png`);
|
||||||
|
const out = join(tmpDir, `${prefix}.mp4`);
|
||||||
|
const threads = String(transcode.threads);
|
||||||
|
const niced = (args) => (nice ? run(nice, ['-n', '19', ffmpeg, ...args]) : run(ffmpeg, args));
|
||||||
|
try {
|
||||||
|
const at = String(Math.min(30, Math.floor(row.duration / 2)));
|
||||||
|
// The picture must last as long as the audio; -shortest cuts a 1 fps loop
|
||||||
|
// a few seconds early, so read the audio stream's own length.
|
||||||
|
const pa = await run(ffprobe, ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=duration', '-of', 'csv=p=0', src]);
|
||||||
|
const seconds = Number(String(pa.stdout).trim()) > 0 ? Number(String(pa.stdout).trim()) : row.duration;
|
||||||
|
const f = await niced(['-v', 'error', '-nostdin', '-y', '-ss', at, '-i', src, '-frames:v', '1',
|
||||||
|
'-vf', 'scale=-2:min(720\\,ih)', frame]);
|
||||||
|
if (f.code !== 0) throw new Error('still frame grab failed: ' + tail(f.stderr));
|
||||||
|
const r = await niced(['-v', 'error', '-nostdin', '-y', '-loop', '1', '-framerate', '1', '-i', frame, '-i', src,
|
||||||
|
'-map', '0:v', '-map', '1:a:0', '-t', String(seconds),
|
||||||
|
'-c:v', 'libx264', '-preset', 'medium', '-crf', String(transcode.crf), '-tune', 'stillimage',
|
||||||
|
'-profile:v', 'high', '-level:v', '4.0', '-pix_fmt', 'yuv420p', '-g', '600', '-threads', threads,
|
||||||
|
'-c:a', 'copy', '-movflags', '+faststart', '-f', 'mp4', out]);
|
||||||
|
if (r.code !== 0) throw new Error('still encode failed: ' + tail(r.stderr));
|
||||||
|
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: ['h264'] });
|
||||||
|
await commitOptimized(id, row, src, out, probe, 'still');
|
||||||
|
} catch (err) {
|
||||||
|
await markDone().catch(() => {});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
sweepTmp(prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sampleRatio(src, duration, videoArgs, prefix) {
|
async function sampleRatio(src, duration, videoArgs, prefix) {
|
||||||
let srcBytes = 0;
|
let srcBytes = 0;
|
||||||
let encBytes = 0;
|
let encBytes = 0;
|
||||||
@@ -733,5 +794,6 @@ export function createMediaCache({
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available };
|
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available,
|
||||||
|
evictNow: id => evict(id, 'admin') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
|
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
@@ -36,6 +36,11 @@ beforeAll(async () => {
|
|||||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4'));
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4'));
|
||||||
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
|
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
|
||||||
'-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4'));
|
'-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4'));
|
||||||
|
// Album-art upload: one picture with light grain for 40 s, at YouTube-like bitrate.
|
||||||
|
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=1:duration=1', '-frames:v', '1', fx('cover.png'));
|
||||||
|
ff('-loop', '1', '-framerate', '25', '-i', fx('cover.png'), '-f', 'lavfi', '-i', 'sine=frequency=330:duration=40',
|
||||||
|
'-vf', 'noise=alls=4:allf=t', '-t', '40', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '600k', '-g', '125',
|
||||||
|
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', fx('still40.mp4'));
|
||||||
// HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1).
|
// HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1).
|
||||||
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
|
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
|
||||||
'-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error',
|
'-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error',
|
||||||
@@ -54,7 +59,7 @@ beforeAll(async () => {
|
|||||||
ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4'));
|
ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4'));
|
||||||
const fs = readFileSync(fx('good-fs.mp4'));
|
const fs = readFileSync(fx('good-fs.mp4'));
|
||||||
writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6)));
|
writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6)));
|
||||||
});
|
}, 30000);
|
||||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
describe('validateMedia', () => {
|
describe('validateMedia', () => {
|
||||||
@@ -93,6 +98,7 @@ let dirN = 0;
|
|||||||
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
||||||
const dir = join(root, 'media' + dirN++);
|
const dir = join(root, 'media' + dirN++);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const sources = [];
|
||||||
const db = {
|
const db = {
|
||||||
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
|
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
|
||||||
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
|
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
|
||||||
@@ -102,13 +108,13 @@ function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
|||||||
const cache = createMediaCache({
|
const cache = createMediaCache({
|
||||||
dir, db, log: silentLog,
|
dir, db, log: silentLog,
|
||||||
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
|
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
|
||||||
download: async (id, out) => { calls.push(id); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
|
download: async (id, out, sourceUrl) => { calls.push(id); sources.push({ id, sourceUrl }); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
|
||||||
transcode: { enabled: false },
|
transcode: { enabled: false },
|
||||||
freeBytes: () => 100 * 1024 ** 3,
|
freeBytes: () => 100 * 1024 ** 3,
|
||||||
backfillDelayMs: -1,
|
backfillDelayMs: -1,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
return { cache, dir, calls, state };
|
return { cache, dir, calls, sources, state };
|
||||||
}
|
}
|
||||||
async function clearDb() {
|
async function clearDb() {
|
||||||
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
|
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
|
||||||
@@ -233,6 +239,19 @@ describe('media cache jobs', () => {
|
|||||||
expect(calls.length).toBe(2);
|
expect(calls.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('missing cached files are re-fetched from the stored source URL', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const id = 'srcAAAAAA01';
|
||||||
|
const sourceUrl = `https://www.youtube.com/watch?v=${id}`;
|
||||||
|
const { cache, dir, calls, sources } = makeCache({ getSourceUrl: async () => sourceUrl });
|
||||||
|
await cache.init();
|
||||||
|
const first = await cache.ensureCached(id, { priority: HIGH });
|
||||||
|
unlinkSync(join(dir, `${id}.${first.gen}.mp4`));
|
||||||
|
await cache.ensureCached(id, { priority: HIGH });
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(sources.every(call => call.id === id && call.sourceUrl === sourceUrl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
|
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
|
||||||
await clearDb();
|
await clearDb();
|
||||||
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
|
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
|
||||||
@@ -257,6 +276,21 @@ describe('media cache jobs', () => {
|
|||||||
expect(await cache.getReady('lruAAAAAAA3')).not.toBeNull();
|
expect(await cache.getReady('lruAAAAAAA3')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pinned copies are skipped while automatic LRU eviction selects another row', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const size = readFileSync(fx('good.mp4')).length;
|
||||||
|
const { cache } = makeCache({ maxBytes: size * 2 + 60 * 250 * 1024 + 50_000, duration: 10 });
|
||||||
|
await cache.init();
|
||||||
|
await cache.ensureCached('pinAAAAAA01', { priority: HIGH });
|
||||||
|
await cache.ensureCached('pinAAAAAA02', { priority: HIGH });
|
||||||
|
await dbmod.upsertMedia('pinAAAAAA01', { last_access: 1000, pinned: 1 });
|
||||||
|
await dbmod.upsertMedia('pinAAAAAA02', { last_access: 2000 });
|
||||||
|
await cache.ensureCached('pinAAAAAA03', { priority: HIGH });
|
||||||
|
expect((await dbmod.getMedia('pinAAAAAA01')).pinned).toBe(1);
|
||||||
|
expect(await dbmod.getMedia('pinAAAAAA02')).toBeNull();
|
||||||
|
expect(await cache.getReady('pinAAAAAA03')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('eviction follows listMediaEvictionOrder when the db provides it', async () => {
|
test('eviction follows listMediaEvictionOrder when the db provides it', async () => {
|
||||||
await clearDb();
|
await clearDb();
|
||||||
const size = readFileSync(fx('good.mp4')).length;
|
const size = readFileSync(fx('good.mp4')).length;
|
||||||
@@ -367,6 +401,44 @@ describe('media cache jobs', () => {
|
|||||||
expect(lines.some((l) => /sample predicts/.test(l))).toBe(false);
|
expect(lines.some((l) => /sample predicts/.test(l))).toBe(false);
|
||||||
}, 90000);
|
}, 90000);
|
||||||
|
|
||||||
|
test('still-picture lane: one looped frame in H.264, far smaller, still verifies', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const lines = [];
|
||||||
|
const { cache } = makeCache({ fixture: 'still40.mp4', duration: 40, log: { info: (m) => lines.push(m), warn: (m) => lines.push(m) },
|
||||||
|
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } });
|
||||||
|
await cache.init();
|
||||||
|
const first = await cache.ensureCached('stilAAAAAA1', { priority: HIGH });
|
||||||
|
expect(await until(async () => (await dbmod.getMedia('stilAAAAAA1')).optimized === OPT_REV, 60000)).toBe(true);
|
||||||
|
const after = await dbmod.getMedia('stilAAAAAA1');
|
||||||
|
expect(after.gen).toBeGreaterThan(first.gen);
|
||||||
|
expect(after.vcodec).toBe('h264');
|
||||||
|
expect(after.size).toBeLessThan(first.size * 0.6);
|
||||||
|
expect(lines.some((l) => /\(still\)/.test(l))).toBe(true);
|
||||||
|
expect(await cache.verify('stilAAAAAA1')).toBe(true);
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
test('a moving copy already done by the HEVC lane is not re-encoded for rev 3', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const seed = makeCache({ fixture: 'fat64.mp4', duration: 64,
|
||||||
|
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } });
|
||||||
|
await seed.cache.init();
|
||||||
|
await seed.cache.ensureCached('rev2AAAAAA1', { priority: HIGH });
|
||||||
|
await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 60000);
|
||||||
|
// Pretend it was evaluated by rev 2 and kept: rev 3 must only run the still check.
|
||||||
|
await dbmod.upsertMedia('rev2AAAAAA1', { optimized: 2 });
|
||||||
|
const before = await dbmod.getMedia('rev2AAAAAA1');
|
||||||
|
// A fresh instance over the same dir + DB, as after a deploy.
|
||||||
|
const again = createMediaCache({
|
||||||
|
dir: seed.dir, db: { getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
|
||||||
|
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia, mediaStats: dbmod.mediaStats },
|
||||||
|
log: silentLog, getInfo: async () => ({ duration: 64 }), download: async () => { throw new Error('no download expected'); },
|
||||||
|
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 }, freeBytes: () => 100 * 1024 ** 3,
|
||||||
|
});
|
||||||
|
await again.init();
|
||||||
|
expect(await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 30000)).toBe(true);
|
||||||
|
expect((await dbmod.getMedia('rev2AAAAAA1')).gen).toBe(before.gen);
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
test('copies evaluated by an older lane revision are re-queued at boot', async () => {
|
test('copies evaluated by an older lane revision are re-queued at boot', async () => {
|
||||||
await clearDb();
|
await clearDb();
|
||||||
const seed = makeCache();
|
const seed = makeCache();
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { join } from 'node:path';
|
|||||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||||
import { registerAnalyticsRoutes } from './admin-analytics.js';
|
import { registerAnalyticsRoutes } from './admin-analytics.js';
|
||||||
import { registerTranscriptionRoutes } from './transcriptions.js';
|
import { registerTranscriptionRoutes } from './transcriptions.js';
|
||||||
|
import { audit, registerAuditRoutes } from './audit-log.js';
|
||||||
|
|
||||||
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
|
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
|
||||||
// A YouTube id or one of the server's own uploads (see uploads.js).
|
// A YouTube id or one of the server's own uploads (see uploads.js).
|
||||||
@@ -314,6 +315,7 @@ export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now()
|
|||||||
export function registerNoteRoutes(app, deps) {
|
export function registerNoteRoutes(app, deps) {
|
||||||
const {
|
const {
|
||||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
||||||
|
catalog = null, media = null,
|
||||||
} = deps;
|
} = deps;
|
||||||
const ADMIN_COOKIE = 'ytp_admin';
|
const ADMIN_COOKIE = 'ytp_admin';
|
||||||
const ADMIN_TTL = 30 * 24 * 3600;
|
const ADMIN_TTL = 30 * 24 * 3600;
|
||||||
@@ -506,6 +508,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
|
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
|
||||||
const data = sanitizeLyrics({ lines: r.lines.map((l) => ({ ...l, kind: 'line' })), tags: [], offset: 0 });
|
const data = sanitizeLyrics({ lines: r.lines.map((l) => ({ ...l, kind: 'line' })), tags: [], offset: 0 });
|
||||||
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
||||||
|
await audit(db, who.by, 'lyrics.save.auto-captions', id, { rev: saved.rev, lines: data.lines.length });
|
||||||
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, lang: r.lang, auto: r.auto });
|
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, lang: r.lang, auto: r.auto });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: err.message }, 502);
|
return c.json({ ok: false, error: err.message }, 502);
|
||||||
@@ -550,6 +553,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
||||||
|
await audit(db, who.by, 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length });
|
||||||
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
|
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: err.message }, 502);
|
return c.json({ ok: false, error: err.message }, 502);
|
||||||
@@ -577,6 +581,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
if (!r.ok) return c.json({ ok: false, error: 'someone else saved a newer version', conflict: true, current: r.current }, 409);
|
if (!r.ok) return c.json({ ok: false, error: 'someone else saved a newer version', conflict: true, current: r.current }, 409);
|
||||||
if (kind === 'lyrics') autoResolve(id, data);
|
if (kind === 'lyrics') autoResolve(id, data);
|
||||||
|
await audit(db, who.by, `notes.save.${kind}`, id, { rev: r.rev, source: who.via });
|
||||||
return c.json({ ok: true, rev: r.rev, data });
|
return c.json({ ok: true, rev: r.rev, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: err.message }, 500);
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
@@ -623,8 +628,9 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
if (fails.length >= 10) return c.json({ ok: false, error: 'too many attempts — try again later' }, 429);
|
if (fails.length >= 10) return c.json({ ok: false, error: 'too many attempts — try again later' }, 429);
|
||||||
let body = {};
|
let body = {};
|
||||||
try { body = await c.req.json(); } catch { /* treated as empty password */ }
|
try { body = await c.req.json(); } catch { /* treated as empty password */ }
|
||||||
const given = createHash('sha256').update(String(body.password || '')).digest();
|
// A pasted password often carries a stray space or line break.
|
||||||
const want = createHash('sha256').update(adminPassword).digest();
|
const given = createHash('sha256').update(String(body.password || '').trim()).digest();
|
||||||
|
const want = createHash('sha256').update(adminPassword.trim()).digest();
|
||||||
if (!timingSafeEqual(given, want)) {
|
if (!timingSafeEqual(given, want)) {
|
||||||
fails.push(now);
|
fails.push(now);
|
||||||
failedLogins.set(ip, fails);
|
failedLogins.set(ip, fails);
|
||||||
@@ -657,11 +663,13 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
const token = newApiToken();
|
const token = newApiToken();
|
||||||
const id = randomBytes(6).toString('hex');
|
const id = randomBytes(6).toString('hex');
|
||||||
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
|
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
|
||||||
|
await audit(db, 'admin', 'token.create', id, { label });
|
||||||
return c.json({ ok: true, id, label, token });
|
return c.json({ ok: true, id, label, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/tokens/:tid', requireAdmin, async (c) => {
|
app.delete('/api/admin/tokens/:tid', requireAdmin, async (c) => {
|
||||||
const ok = await db.deleteApiToken(c.req.param('tid'));
|
const ok = await db.deleteApiToken(c.req.param('tid'));
|
||||||
|
if (ok) await audit(db, 'admin', 'token.revoke', c.req.param('tid'));
|
||||||
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
|
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -672,9 +680,11 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
registerAuditRoutes(app, { db, adminAuth: requireAdminOrToken });
|
||||||
|
|
||||||
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
|
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
|
||||||
|
|
||||||
registerTranscriptionRoutes(app, {
|
const transcriptionService = registerTranscriptionRoutes(app, {
|
||||||
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
|
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
|
||||||
workerEnabled: !!workerToken && workerToken.length >= 24,
|
workerEnabled: !!workerToken && workerToken.length >= 24,
|
||||||
workerAuth: async (c, next) => {
|
workerAuth: async (c, next) => {
|
||||||
@@ -695,6 +705,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
const n = await db.getNotes(r.video_id);
|
const n = await db.getNotes(r.video_id);
|
||||||
out.push({
|
out.push({
|
||||||
id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
|
id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
|
||||||
|
sourceUrl: catalog ? await catalog.getSourceUrl(r.video_id) : null,
|
||||||
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
|
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
|
||||||
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
|
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
|
||||||
});
|
});
|
||||||
@@ -703,6 +714,16 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
return c.json({ ok: true, media: out });
|
return c.json({ ok: true, media: out });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => {
|
||||||
|
if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
|
||||||
|
try {
|
||||||
|
const result = await media.redownload(c.req.param('id'));
|
||||||
|
await audit(db, 'admin', 'media.redownload', c.req.param('id'), result);
|
||||||
|
return c.json({ ok: true, ...result }, 202);
|
||||||
|
}
|
||||||
|
catch (err) { return c.json({ ok: false, error: err.message }, 400); }
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
||||||
|
|
||||||
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
||||||
@@ -719,6 +740,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
updatedBy: `admin (rev ${old.rev})`, force: true,
|
updatedBy: `admin (rev ${old.rev})`, force: true,
|
||||||
});
|
});
|
||||||
if (kind === 'lyrics') autoResolve(id, restored);
|
if (kind === 'lyrics') autoResolve(id, restored);
|
||||||
|
await audit(db, 'admin', `notes.restore.${kind}`, id, { fromRev: old.rev, newRev: r.rev });
|
||||||
return c.json({ ok: true, rev: r.rev });
|
return c.json({ ok: true, rev: r.rev });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -741,12 +763,24 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/api/admin/flags', requireAdminOrToken, async (c) => {
|
app.get('/api/admin/flags', requireAdminOrToken, async (c) => {
|
||||||
const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open';
|
const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open';
|
||||||
|
const olderThanDays = Math.max(0, Math.min(3650, Number(c.req.query('olderThanDays')) || 0));
|
||||||
|
const olderThan = olderThanDays ? Math.floor(Date.now() / 1000) - olderThanDays * 86400 : 0;
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
const flags = [];
|
const flags = [];
|
||||||
for (const f of await db.listAllFlags({ status, limit: 300 })) {
|
for (const f of await db.listAllFlags({ status, limit: 300, olderThan })) {
|
||||||
flags.push({ ...f, ...(await songLabel(f.videoId, cache)) });
|
flags.push({ ...f, ...(await songLabel(f.videoId, cache)) });
|
||||||
}
|
}
|
||||||
return c.json({ ok: true, open: await db.countOpenFlags(), flags });
|
return c.json({ ok: true, open: await db.countOpenFlags(), flags, olderThanDays });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/flags/song/:videoId/resolve', requireAdmin, async (c) => {
|
||||||
|
const videoId = c.req.param('videoId');
|
||||||
|
if (badId(videoId)) return c.json({ ok: false, error: 'invalid video id' }, 400);
|
||||||
|
const flags = await db.listFlagsForVideo(videoId, 'open');
|
||||||
|
let resolved = 0;
|
||||||
|
for (const flag of flags) if (await db.setFlagStatus(flag.id, 'resolved', 'admin')) resolved++;
|
||||||
|
if (resolved) await audit(db, 'admin', 'report.song.resolve', videoId, { resolved });
|
||||||
|
return c.json({ ok: true, resolved, open: await db.countOpenFlags() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/flags/:fid', requireAdmin, async (c) => {
|
app.post('/api/admin/flags/:fid', requireAdmin, async (c) => {
|
||||||
@@ -755,11 +789,14 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
try { body = await c.req.json(); } catch { /* status required below */ }
|
try { body = await c.req.json(); } catch { /* status required below */ }
|
||||||
if (!Number.isInteger(fid) || !['open', 'resolved'].includes(body.status)) return c.json({ ok: false, error: 'bad request' }, 400);
|
if (!Number.isInteger(fid) || !['open', 'resolved'].includes(body.status)) return c.json({ ok: false, error: 'bad request' }, 400);
|
||||||
const ok = await db.setFlagStatus(fid, body.status, 'admin');
|
const ok = await db.setFlagStatus(fid, body.status, 'admin');
|
||||||
|
if (ok) await audit(db, 'admin', `report.${body.status}`, String(fid));
|
||||||
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/flags/:fid', requireAdmin, async (c) => {
|
app.delete('/api/admin/flags/:fid', requireAdmin, async (c) => {
|
||||||
const ok = await db.deleteFlag(Number(c.req.param('fid')));
|
const id = Number(c.req.param('fid'));
|
||||||
|
const ok = await db.deleteFlag(id);
|
||||||
|
if (ok) await audit(db, 'admin', 'report.delete', String(id));
|
||||||
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -791,8 +828,38 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
console.warn('[notes] backup failed:', err.message);
|
console.warn('[notes] backup failed:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function pullLyricsForAdmin(id) {
|
||||||
|
if (badId(id)) return { ok: false, error: 'invalid video id' };
|
||||||
|
const existing = (await db.getNotes(id)).lyrics;
|
||||||
|
if (existing?.data?.lines?.length) return { ok: false, error: 'lyrics already exist; clear or edit them before pulling LRCLIB', rev: existing.rev };
|
||||||
|
let meta = {}, row = null;
|
||||||
|
if (id.startsWith('upl_')) {
|
||||||
|
const upload = await db.getUpload(id);
|
||||||
|
if (upload) meta = { title: upload.title, channel: upload.artist, album: upload.album, duration: upload.duration };
|
||||||
|
} else {
|
||||||
|
row = await db.getMedia(id);
|
||||||
|
try { meta = JSON.parse(row?.meta || '{}'); } catch {}
|
||||||
|
try {
|
||||||
|
const catalogRow = (await (db.db || db).execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [id] })).rows[0];
|
||||||
|
meta = { ...JSON.parse(catalogRow?.card || '{}'), ...meta };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const title = cleanTitle(meta.title || ''), artist = cleanArtist(meta.channel || meta.artist || meta.uploader || ''), duration = Number(row?.duration || meta.duration || 0) || 0;
|
||||||
|
if (!title) return { ok: false, error: 'no title known for this video' };
|
||||||
|
const hit = await lrclibLookup({ title, artist, album: meta.album, duration });
|
||||||
|
if (!hit) return { ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` };
|
||||||
|
const data = sanitizeLyrics({ lines: hit.lines, tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'], offset: 0 });
|
||||||
|
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'admin bulk LRCLIB', force: true });
|
||||||
|
await db.autoResolveFlags(id, new Set(data.lines.map(line => line.text)));
|
||||||
|
await audit(db, 'admin', 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length, bulk: true });
|
||||||
|
return { ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
requireAdminOrToken,
|
requireAdminOrToken,
|
||||||
|
requireAdmin,
|
||||||
|
adminAuth: requireAdmin,
|
||||||
|
pullLyricsForAdmin,
|
||||||
|
queueWhisperForAdmin: transcriptionService.enqueue,
|
||||||
startBackups() {
|
startBackups() {
|
||||||
setTimeout(writeBackup, 60_000);
|
setTimeout(writeBackup, 60_000);
|
||||||
setInterval(writeBackup, 24 * 3600_000).unref?.();
|
setInterval(writeBackup, 24 * 3600_000).unref?.();
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ beforeAll(async () => {
|
|||||||
backupDir: join(root, 'backups'),
|
backupDir: join(root, 'backups'),
|
||||||
adminHtmlPath: htmlPath,
|
adminHtmlPath: htmlPath,
|
||||||
workerToken: 'w'.repeat(32),
|
workerToken: 'w'.repeat(32),
|
||||||
|
catalog: { getSourceUrl: async id => /^[\w-]{11}$/.test(id) ? `https://www.youtube.com/watch?v=${id}` : null },
|
||||||
|
media: { redownload: async id => ({ status: `queued:${id}` }) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,5 +280,41 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy
|
|||||||
const cookie = await adminCookie();
|
const cookie = await adminCookie();
|
||||||
const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } });
|
const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } });
|
||||||
const song = (await res.json()).media.find(x => x.id === id);
|
const song = (await res.json()).media.find(x => x.id === id);
|
||||||
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed' });
|
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed', sourceUrl: 'https://www.youtube.com/watch?v=phoneSong01' });
|
||||||
|
expect((await app.request(`/api/admin/media/${id}/redownload`, { method: 'POST', headers: { Cookie: cookie } })).status).toBe(202);
|
||||||
|
expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Whisper enqueue reports a full queue without depending on request context', async () => {
|
||||||
|
await dbmod.upsertMedia(VID, { status: 'ready', duration: 60 });
|
||||||
|
const activeIds = Array.from({ length: 25 }, (_, index) => `QF${String(index).padStart(9, '0')}`);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await dbmod.db.execute({
|
||||||
|
sql: `INSERT INTO lyric_transcriptions(id,video_id,status,base_rev,created_at,updated_at) VALUES ${activeIds.map(() => '(?,? ,\'queued\',0,?,?)').join(',')}`,
|
||||||
|
args: activeIds.flatMap((id, index) => [String(index), id, now, now]),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const cookie = await adminCookie();
|
||||||
|
const response = await app.request(`/api/admin/transcriptions/${VID}`, json('POST', {}, { Cookie: cookie }));
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(await response.json()).toMatchObject({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' });
|
||||||
|
} finally {
|
||||||
|
await dbmod.db.execute({ sql: `DELETE FROM lyric_transcriptions WHERE video_id IN (${activeIds.map(() => '?').join(',')})`, args: activeIds });
|
||||||
|
await dbmod.deleteMedia(VID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('report triage filters aging flags and resolves every open report for one song', async () => {
|
||||||
|
const id = VID;
|
||||||
|
const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' });
|
||||||
|
const b = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'typo', note: '', reporter: 'listener-b' });
|
||||||
|
await dbmod.db.execute({ sql: 'UPDATE lyric_flags SET created_at=? WHERE id IN (?,?)', args: [Math.floor(Date.now() / 1000) - 10 * 86400, a.id, b.id] });
|
||||||
|
const cookie = await adminCookie();
|
||||||
|
const aged = await (await app.request('/api/admin/flags?status=open&olderThanDays=7', { headers: { Cookie: cookie } })).json();
|
||||||
|
expect(aged.flags.filter(flag => flag.videoId === id)).toHaveLength(2);
|
||||||
|
const unauthorized = await app.request(`/api/admin/flags/song/${id}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
expect(unauthorized.status).toBe(401);
|
||||||
|
const response = await app.request(`/api/admin/flags/song/${id}/resolve`, json('POST', {}, { Cookie: cookie }));
|
||||||
|
expect(await response.json()).toMatchObject({ ok: true, resolved: 2 });
|
||||||
|
expect((await dbmod.listFlagsForVideo(id, 'open'))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
36
server/p2p-admin.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { audit } from './audit-log.js';
|
||||||
|
|
||||||
|
export function registerP2pAdminRoutes(app, { db, adminAuth, p2pDb, hub, stats = async () => ({}) }) {
|
||||||
|
app.get('/api/admin/p2p/devices', adminAuth, async c => {
|
||||||
|
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
|
||||||
|
const page = await p2pDb.listAdminDevices({ limit: 101, offset });
|
||||||
|
const devices = page.slice(0, 100);
|
||||||
|
return c.json({ ok: true, hasMore: page.length > 100, devices: devices.map(d => ({
|
||||||
|
id: d.device_id, fingerprint: d.fingerprint || '', fingerprintShort: (d.fingerprint || d.device_id).slice(0, 12),
|
||||||
|
profile: d.profile || '', userAgent: d.user_agent || '', share: !!Number(d.share), banned: !!d.banned_at,
|
||||||
|
lastSeenAt: Number(d.last_seen_at), online: hub.isOnline(d.device_id), holdings: Number(d.holdings), holdingBytes: Number(d.holding_bytes),
|
||||||
|
bytesUp: Number(d.bytes_up), bytesDown: Number(d.bytes_down),
|
||||||
|
})), network: { online: hub.onlineCount(), ...(await stats()) }, offset }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
app.post('/api/admin/p2p/devices/:id/ban', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const body = await c.req.json().catch(() => ({})); const banned = body.banned === true;
|
||||||
|
const changed = await p2pDb.setDeviceBanned(id, banned);
|
||||||
|
if (!changed) return c.json({ ok: false, error: 'Device not found.' }, 404);
|
||||||
|
if (banned) hub.disconnect(id);
|
||||||
|
await audit(db, 'admin', banned ? 'p2p.device.ban' : 'p2p.device.unban', id);
|
||||||
|
return c.json({ ok: true, banned });
|
||||||
|
});
|
||||||
|
app.post('/api/admin/p2p/devices/:id/reannounce', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const sent = hub.send(id, { type: 'force-sync' });
|
||||||
|
await audit(db, 'admin', 'p2p.device.reannounce', id, { sent });
|
||||||
|
return c.json({ ok: true, sent });
|
||||||
|
});
|
||||||
|
app.delete('/api/admin/p2p/devices/:id/holdings', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const removed = await p2pDb.removeDeviceHolders(id);
|
||||||
|
await audit(db, 'admin', 'p2p.device.holdings.revoke', id, { removed });
|
||||||
|
return c.json({ ok: true, removed });
|
||||||
|
});
|
||||||
|
}
|
||||||
26
server/p2p-admin.test.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { registerP2pAdminRoutes } from './p2p-admin.js';
|
||||||
|
|
||||||
|
test('P2P admin actions require auth, log mutations, and control online devices', async () => {
|
||||||
|
const events = [], calls = [];
|
||||||
|
const db = { execute: async query => { events.push(query.args); return { rowsAffected: 1 }; } };
|
||||||
|
const p2pDb = {
|
||||||
|
listAdminDevices: async ({ limit }) => { expect(limit).toBe(101); return [{ device_id: 'dev_0123456789abcdef', fingerprint: 'fingerprint', profile: 'home', user_agent: 'iPhone', share: 1, banned_at: null, last_seen_at: 42, holdings: 2, holding_bytes: 80, bytes_up: 4, bytes_down: 3 }]; },
|
||||||
|
setDeviceBanned: async (id, banned) => { calls.push(['ban', id, banned]); return true; },
|
||||||
|
removeDeviceHolders: async id => { calls.push(['remove', id]); return 2; },
|
||||||
|
};
|
||||||
|
const hub = { isOnline: () => true, onlineCount: () => 1, send: (...args) => { calls.push(['send', ...args]); return true; }, disconnect: id => calls.push(['disconnect', id]) };
|
||||||
|
const app = new Hono();
|
||||||
|
registerP2pAdminRoutes(app, { db, p2pDb, hub, stats: async () => ({ content: 5 }), adminAuth: async (c, next) => { if (c.req.header('x-admin') !== 'yes') return c.json({ ok:false },401); await next(); } });
|
||||||
|
expect((await app.request('/api/admin/p2p/devices')).status).toBe(401);
|
||||||
|
const list = await app.request('/api/admin/p2p/devices', { headers: { 'x-admin': 'yes' } });
|
||||||
|
expect(await list.json()).toMatchObject({ ok: true, network: { online: 1, content: 5 }, devices: [{ id: 'dev_0123456789abcdef', online: true, holdingBytes: 80 }] });
|
||||||
|
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
|
||||||
|
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/ban', { method:'POST',headers,body:'{"banned":true}' })).status).toBe(200);
|
||||||
|
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/reannounce', { method:'POST',headers,body:'{}' })).status).toBe(200);
|
||||||
|
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/holdings', { method:'DELETE',headers })).status).toBe(200);
|
||||||
|
expect(calls.some(x => x[0] === 'disconnect')).toBe(true);
|
||||||
|
expect(calls.some(x => x[0] === 'send' && x[2].type === 'force-sync')).toBe(true);
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
});
|
||||||
@@ -37,6 +37,10 @@ export async function initP2pSchema() {
|
|||||||
fingerprint TEXT,
|
fingerprint TEXT,
|
||||||
profile TEXT,
|
profile TEXT,
|
||||||
share INTEGER NOT NULL DEFAULT 1,
|
share INTEGER NOT NULL DEFAULT 1,
|
||||||
|
user_agent TEXT,
|
||||||
|
banned_at INTEGER,
|
||||||
|
bytes_up INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_down INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_seen_at INTEGER NOT NULL
|
last_seen_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -60,6 +64,10 @@ export async function initP2pSchema() {
|
|||||||
PRIMARY KEY (video_id, day)
|
PRIMARY KEY (video_id, day)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
for (const [name, decl] of [['user_agent','TEXT'],['banned_at','INTEGER'],['bytes_up','INTEGER NOT NULL DEFAULT 0'],['bytes_down','INTEGER NOT NULL DEFAULT 0']]) {
|
||||||
|
try { await db.execute(`ALTER TABLE p2p_devices ADD COLUMN ${name} ${decl}`); }
|
||||||
|
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
||||||
|
}
|
||||||
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
|
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
|
||||||
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
|
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
|
||||||
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
||||||
@@ -117,11 +125,11 @@ export async function revokeContent(cid) {
|
|||||||
|
|
||||||
// ---- devices ------------------------------------------------------------------
|
// ---- devices ------------------------------------------------------------------
|
||||||
|
|
||||||
export async function createDevice({ deviceId, secretHash, fingerprint, profile, now }) {
|
export async function createDevice({ deviceId, secretHash, fingerprint, profile, userAgent, now }) {
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, created_at, last_seen_at)
|
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, user_agent, created_at, last_seen_at)
|
||||||
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||||
args: [deviceId, secretHash, fingerprint || null, profile || null, now, now],
|
args: [deviceId, secretHash, fingerprint || null, profile || null, userAgent || null, now, now],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +147,26 @@ export async function touchDevice(deviceId, { now, share, profile } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminDevices({ limit = 100, offset = 0 } = {}) {
|
||||||
|
const r = await db.execute({ sql: `SELECT d.device_id,d.fingerprint,d.profile,d.share,d.user_agent,d.banned_at,d.bytes_up,d.bytes_down,d.created_at,d.last_seen_at,
|
||||||
|
COUNT(CASE WHEN h.status='active' THEN 1 END) AS holdings,
|
||||||
|
COALESCE(SUM(CASE WHEN h.status='active' THEN c.size ELSE 0 END),0) AS holding_bytes
|
||||||
|
FROM p2p_devices d LEFT JOIN p2p_holders h ON h.device_id=d.device_id LEFT JOIN p2p_content c ON c.cid=h.cid
|
||||||
|
GROUP BY d.device_id ORDER BY d.last_seen_at DESC LIMIT ? OFFSET ?`, args: [Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
|
||||||
|
return rowsOf(r);
|
||||||
|
}
|
||||||
|
export async function setDeviceBanned(deviceId, banned, now = Date.now()) {
|
||||||
|
const r = await db.execute({ sql: 'UPDATE p2p_devices SET banned_at=? WHERE device_id=?', args: [banned ? now : null, deviceId] });
|
||||||
|
return !!r.rowsAffected;
|
||||||
|
}
|
||||||
|
export async function removeDeviceHolders(deviceId, now = Date.now()) {
|
||||||
|
const r = await db.execute({ sql: "UPDATE p2p_holders SET status='removed',removed_at=? WHERE device_id=? AND status='active'", args: [now, deviceId] });
|
||||||
|
return Number(r.rowsAffected || 0);
|
||||||
|
}
|
||||||
|
export async function recordDeviceTraffic(deviceId, { up = 0, down = 0 } = {}) {
|
||||||
|
await db.execute({ sql: 'UPDATE p2p_devices SET bytes_up=bytes_up+?,bytes_down=bytes_down+? WHERE device_id=?', args: [Math.max(0, Number(up) || 0), Math.max(0, Number(down) || 0), deviceId] });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- holders (persistent; never expired by time) ------------------------------
|
// ---- holders (persistent; never expired by time) ------------------------------
|
||||||
|
|
||||||
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {
|
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {
|
||||||
@@ -243,7 +271,7 @@ export async function p2pStats() {
|
|||||||
// only then the qualifying ones, least recently played first.
|
// only then the qualifying ones, least recently played first.
|
||||||
export async function listMediaEvictionOrder({ now, keepMinViews, keepDays, keepRecentDays }) {
|
export async function listMediaEvictionOrder({ now, keepMinViews, keepDays, keepRecentDays }) {
|
||||||
const r = await db.execute({
|
const r = await db.execute({
|
||||||
sql: `SELECT m.video_id, m.size, m.last_access,
|
sql: `SELECT m.video_id, m.size, m.last_access, m.pinned,
|
||||||
COALESCE((SELECT SUM(v.n) FROM video_views v
|
COALESCE((SELECT SUM(v.n) FROM video_views v
|
||||||
WHERE v.video_id = m.video_id AND v.day >= ?), 0) AS views
|
WHERE v.video_id = m.video_id AND v.day >= ?), 0) AS views
|
||||||
FROM media_cache m WHERE m.status = 'ready'`,
|
FROM media_cache m WHERE m.status = 'ready'`,
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ test('content upsert/get/known/revoke', async () => {
|
|||||||
expect([...(await P.knownCids([CID2]))]).toEqual([]);
|
expect([...(await P.knownCids([CID2]))]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin device inventory, ban state, and holding revocation', async () => {
|
||||||
|
const id = 'dev_00000000000000b1';
|
||||||
|
await P.createDevice({ deviceId: id, secretHash: 'hash', fingerprint: 'phone-fp', profile: 'family', userAgent: 'iPhone', now: T0 });
|
||||||
|
const adminCid = 'c'.repeat(64);
|
||||||
|
await P.upsertContent({ cid: adminCid, videoId: 'dQw4w9WgXcQ', size: 4096, origin: 'server', now: T0 });
|
||||||
|
await P.upsertHolder({ cid: adminCid, deviceId: id, now: T0 });
|
||||||
|
let [device] = await P.listAdminDevices();
|
||||||
|
expect(device).toMatchObject({ device_id: id, user_agent: 'iPhone', holdings: 1, holding_bytes: 4096 });
|
||||||
|
expect(await P.setDeviceBanned(id, true, T0 + 100)).toBe(true);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.banned_at).toBe(T0 + 100);
|
||||||
|
expect(await P.removeDeviceHolders(id, T0 + 200)).toBe(1);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.holdings).toBe(0);
|
||||||
|
expect(await P.setDeviceBanned(id, false, T0 + 300)).toBe(true);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.banned_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('holders persist, never expire, and a full report removes missing ones', async () => {
|
test('holders persist, never expire, and a full report removes missing ones', async () => {
|
||||||
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
|
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
|
||||||
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
|
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
|
||||||
@@ -66,8 +82,8 @@ test('views per day and window sums', async () => {
|
|||||||
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
|
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
|
||||||
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
|
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
|
||||||
const s = await P.p2pStats();
|
const s = await P.p2pStats();
|
||||||
expect(s.content).toBe(1);
|
expect(s.content).toBe(2);
|
||||||
expect(s.devices).toBe(1);
|
expect(s.devices).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {
|
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
|
|||||||
const deviceId = String(m.device || '');
|
const deviceId = String(m.device || '');
|
||||||
const secret = String(m.secret || '');
|
const secret = String(m.secret || '');
|
||||||
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
|
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
|
||||||
if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; }
|
if (!d || d.banned_at || !same(d.secret_hash, sha(secret))) { try { ws.close(d?.banned_at ? 4403 : 4401, d?.banned_at ? 'device banned' : 'unknown device'); } catch { /* gone */ } return; }
|
||||||
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
|
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
|
||||||
ws.data.inventory = [];
|
ws.data.inventory = [];
|
||||||
ws.data.authed = true;
|
ws.data.authed = true;
|
||||||
@@ -107,6 +107,8 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
|
|||||||
websocket: { open, message, close },
|
websocket: { open, message, close },
|
||||||
isOnline: (deviceId) => online.has(deviceId),
|
isOnline: (deviceId) => online.has(deviceId),
|
||||||
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
|
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
|
||||||
|
disconnect: (deviceId) => { const ws = online.get(deviceId); if (!ws) return false; try { ws.close(4403, 'device banned'); } catch { /* gone */ } return true; },
|
||||||
|
onlineDevices: () => [...online.keys()],
|
||||||
onlineCount: () => online.size,
|
onlineCount: () => online.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ test('auth on open, hello with opaque peer id, bad secret is closed', async () =
|
|||||||
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
|
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('banned devices cannot authenticate and admin disconnect closes an online peer', async () => {
|
||||||
|
await p2pDb.setDeviceBanned('dev_000000000000000a', true, NOW);
|
||||||
|
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
||||||
|
const banned = fakeWs({ budget: [] });
|
||||||
|
await hub.websocket.message(banned, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
|
||||||
|
expect(banned.closed).toBe(4403);
|
||||||
|
await p2pDb.setDeviceBanned('dev_000000000000000a', false, NOW + 1);
|
||||||
|
const online = fakeWs({ budget: [] });
|
||||||
|
await hub.websocket.message(online, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
|
||||||
|
expect(hub.disconnect('dev_000000000000000a')).toBe(true);
|
||||||
|
expect(online.closed).toBe(4403);
|
||||||
|
hub.websocket.close(online);
|
||||||
|
expect(hub.isOnline('dev_000000000000000a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy unconfirmed signalling is refused; close drops presence', async () => {
|
test('legacy unconfirmed signalling is refused; close drops presence', async () => {
|
||||||
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
||||||
const a = fakeWs({ budget: [] });
|
const a = fakeWs({ budget: [] });
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function registerIntakeRoutes(app, deps) {
|
|||||||
);
|
);
|
||||||
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
|
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
|
||||||
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
|
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
|
||||||
|
await p2pDb.recordDeviceTraffic?.(d.device_id, { up: got });
|
||||||
let adopted = false;
|
let adopted = false;
|
||||||
if (adopt) {
|
if (adopt) {
|
||||||
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }
|
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ export function registerP2pRoutes(app, deps) {
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const d = await p2pDb.getDevice(m[1]);
|
const d = await p2pDb.getDevice(m[1]);
|
||||||
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
|
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
|
||||||
|
if (d.banned_at) return { ...d, _banned: true };
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
const requireDevice = async (c, next) => {
|
const requireDevice = async (c, next) => {
|
||||||
const d = await deviceOf(c);
|
const d = await deviceOf(c);
|
||||||
|
if (d?._banned) return c.json({ ok: false, error: 'device access revoked' }, 403);
|
||||||
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
|
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
|
||||||
c.set('device', d);
|
c.set('device', d);
|
||||||
await next();
|
await next();
|
||||||
@@ -67,6 +69,7 @@ export function registerP2pRoutes(app, deps) {
|
|||||||
deviceId, secretHash: sha(secret),
|
deviceId, secretHash: sha(secret),
|
||||||
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
|
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
|
||||||
profile: String(body.profile || '').slice(0, 64) || null,
|
profile: String(body.profile || '').slice(0, 64) || null,
|
||||||
|
userAgent: String(c.req.header('user-agent') || '').slice(0, 500),
|
||||||
now: t,
|
now: t,
|
||||||
});
|
});
|
||||||
return c.json({ ok: true, deviceId, secret });
|
return c.json({ ok: true, deviceId, secret });
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ test('holdings need a valid device secret', async () => {
|
|||||||
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
|
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('banned device is denied with an explicit forbidden response', async () => {
|
||||||
|
const dev = await newDevice();
|
||||||
|
await p2pDb.setDeviceBanned(dev.deviceId, true);
|
||||||
|
const r = await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev });
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
expect(await r.json()).toMatchObject({ ok: false, error: 'device access revoked' });
|
||||||
|
await p2pDb.setDeviceBanned(dev.deviceId, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
|
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
|
||||||
const dev = await newDevice();
|
const dev = await newDevice();
|
||||||
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [
|
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.js",
|
"start": "bun server.js",
|
||||||
"dev": "bun --hot server.js",
|
"dev": "bun --hot server.js",
|
||||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js"
|
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./request-monitor.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admin.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js && bun test ./profile-recovery.test.js && bun test ./reader.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@mozilla/readability": "^0.6.0",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
|
"linkedom": "^0.18.13",
|
||||||
|
"nodemailer": "^6",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
* any → all {type:'voice', on} (voice-chat presence)
|
* any → all {type:'voice', on} (voice-chat presence)
|
||||||
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
||||||
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
||||||
|
* host → server {type:'invite'|'uninvite', profile} invite an online profile; it sees
|
||||||
|
* the party at GET /api/party/invites (server.js) · server → host {type:'invites', invites}
|
||||||
|
* host → server {type:'pq', action:'add'|'remove'|'move', …} prepared presentations;
|
||||||
|
* server → host {type:'pq', items} (also in hello). Presenting one is a normal 'present'.
|
||||||
|
* host → all {type:'present', present: {url, page} | null} shared document/website
|
||||||
|
* (guests too while the host allows control; kept for late joiners)
|
||||||
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
||||||
* server → all {type:'members', members} · {type:'ended'}
|
* server → all {type:'members', members} · {type:'ended'}
|
||||||
* A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky
|
* A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky
|
||||||
@@ -26,13 +32,19 @@ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
|
|||||||
const PARTY_GRACE_MS = 5 * 60_000;
|
const PARTY_GRACE_MS = 5 * 60_000;
|
||||||
const CHAT_KEEP = 60;
|
const CHAT_KEEP = 60;
|
||||||
const MAX_MSG = 64 * 1024;
|
const MAX_MSG = 64 * 1024;
|
||||||
|
const MSG_BURST = 40, MSG_PER_SEC = 20; // per member: everything except chat, which has its own guard
|
||||||
export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']);
|
export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']);
|
||||||
|
|
||||||
const clean = (v, max, fallback = '') =>
|
const clean = (v, max, fallback = '') =>
|
||||||
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
|
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
|
||||||
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
||||||
|
|
||||||
export function createPartyHub({ now = () => Date.now() } = {}) {
|
const PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
|
||||||
|
const MAX_INVITES = 30;
|
||||||
|
const MAX_QUEUE = 50;
|
||||||
|
const URL_RE = /^https?:\/\/[^\s<>"]{3,2000}$/i;
|
||||||
|
|
||||||
|
export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
|
||||||
const parties = new Map(); // code → party
|
const parties = new Map(); // code → party
|
||||||
const joinFails = new Map();
|
const joinFails = new Map();
|
||||||
|
|
||||||
@@ -40,6 +52,20 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
|
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
|
||||||
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
|
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
|
||||||
|
|
||||||
|
const inviteList = (p) => [...p.invites.keys()];
|
||||||
|
// Parties that invited this profile and are still running.
|
||||||
|
function invitesFor(profile) {
|
||||||
|
const name = String(profile || '').toLowerCase(), out = [];
|
||||||
|
for (const p of parties.values()) {
|
||||||
|
const inv = p.invites.get(name);
|
||||||
|
if (!inv) continue;
|
||||||
|
const host = p.members.get(p.hostPid);
|
||||||
|
out.push({ code: p.code, host: host ? host.name : inv.by, hostOnline: p.hostOnline, members: p.members.size,
|
||||||
|
song: p.state && p.state.v ? p.state.v.title || '' : '', at: inv.at });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.at - a.at);
|
||||||
|
}
|
||||||
|
|
||||||
function newCode() {
|
function newCode() {
|
||||||
let c;
|
let c;
|
||||||
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
|
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
|
||||||
@@ -73,24 +99,26 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
if (!parties.has(code)) {
|
if (!parties.has(code)) {
|
||||||
fails.push(t); joinFails.set(ip, fails);
|
fails.push(t); joinFails.set(ip, fails);
|
||||||
if (joinFails.size > 5000) joinFails.clear();
|
if (joinFails.size > 5000) joinFails.clear();
|
||||||
return new Response('no such party', { status: 404 });
|
|
||||||
}
|
}
|
||||||
} else if (role !== 'host') {
|
} else if (role !== 'host') {
|
||||||
return new Response('unknown role', { status: 400 });
|
return new Response('unknown role', { status: 400 });
|
||||||
}
|
}
|
||||||
const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret') } });
|
// Unknown codes still upgrade so open() can answer with close code 4004 —
|
||||||
|
// a plain HTTP 404 made the client retry forever.
|
||||||
|
const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret'), key: q('key').slice(0, 64), ip } });
|
||||||
return ok ? undefined : new Response('upgrade failed', { status: 400 });
|
return ok ? undefined : new Response('upgrade failed', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(ws) {
|
function open(ws) {
|
||||||
const d = ws.data;
|
const d = ws.data;
|
||||||
let p;
|
let p, lost = false;
|
||||||
if (d.role === 'host') {
|
if (d.role === 'host') {
|
||||||
p = d.code && parties.get(d.code);
|
p = d.code && parties.get(d.code);
|
||||||
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
||||||
if (!p) {
|
if (!p) {
|
||||||
|
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
||||||
const code = newCode();
|
const code = newCode();
|
||||||
p = { direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
p = { invites: new Map(), pq: [], present: null, direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||||
parties.set(code, p);
|
parties.set(code, p);
|
||||||
}
|
}
|
||||||
p.hostPid = d.pid;
|
p.hostPid = d.pid;
|
||||||
@@ -99,13 +127,18 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
} else {
|
} else {
|
||||||
p = parties.get(d.code);
|
p = parties.get(d.code);
|
||||||
if (!p) { ws.close(4004, 'no such party'); return; }
|
if (!p) { ws.close(4004, 'no such party'); return; }
|
||||||
|
// Participant ids are public (members list); a guest may not claim the host's.
|
||||||
|
if (d.pid === p.hostPid) { ws.close(4003, 'not the host of this party'); return; }
|
||||||
}
|
}
|
||||||
const prev = p.members.get(d.pid);
|
const prev = p.members.get(d.pid);
|
||||||
|
// Only the same device (same private key) may take over a participant's seat.
|
||||||
|
if (prev && prev.ws !== ws && prev.key && d.role !== 'host' && !same(prev.key, d.key || '')) { ws.close(4005, 'participant id in use'); return; }
|
||||||
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
||||||
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
|
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0, key: d.key || '', host: d.role === 'host', tokens: MSG_BURST, refill: now() });
|
||||||
send(ws, {
|
send(ws, {
|
||||||
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
|
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, lost,
|
||||||
now: now(), members: members(p), state: p.state, chat: p.chat, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
|
now: now(), members: members(p), state: p.state, chat: p.chat, present: p.present,
|
||||||
|
invites: d.role === 'host' ? inviteList(p) : undefined, pq: d.role === 'host' ? p.pq : undefined, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
|
||||||
});
|
});
|
||||||
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
||||||
}
|
}
|
||||||
@@ -118,8 +151,16 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
const p = parties.get(d.code);
|
const p = parties.get(d.code);
|
||||||
const me = p && p.members.get(d.pid);
|
const me = p && p.members.get(d.pid);
|
||||||
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
||||||
const isHost = d.pid === p.hostPid;
|
const isHost = me.host && d.pid === p.hostPid;
|
||||||
if (m.type === 'direct') {
|
if (m.type !== 'chat') {
|
||||||
|
const t = now();
|
||||||
|
me.tokens = Math.min(MSG_BURST, me.tokens + ((t - me.refill) / 1000) * MSG_PER_SEC); me.refill = t;
|
||||||
|
if (me.tokens < 1) return; // flood: drop
|
||||||
|
me.tokens -= 1;
|
||||||
|
}
|
||||||
|
if (m.type === 'ping') {
|
||||||
|
send(ws, { type: 'pong', echo: Number(m.echo) || 0, now: now() });
|
||||||
|
} else if (m.type === 'direct') {
|
||||||
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
||||||
} else if (m.type === 'dj') {
|
} else if (m.type === 'dj') {
|
||||||
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
|
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
|
||||||
@@ -127,6 +168,37 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
} else if (m.type === 'state' && isHost) {
|
} else if (m.type === 'state' && isHost) {
|
||||||
p.state = { ...(m.state || {}), ts: now() };
|
p.state = { ...(m.state || {}), ts: now() };
|
||||||
broadcast(p, { type: 'state', state: p.state }, ws);
|
broadcast(p, { type: 'state', state: p.state }, ws);
|
||||||
|
} else if (m.type === 'present' && (isHost || p.allowControl)) {
|
||||||
|
const v = m.present;
|
||||||
|
if (v === null) p.present = null;
|
||||||
|
else {
|
||||||
|
const url = String(v?.url || '').trim();
|
||||||
|
if (!/^https?:\/\/[^\s<>"]{3,2000}$/i.test(url)) return;
|
||||||
|
p.present = { url, mode: v.mode === 'reader' ? 'reader' : 'embed', page: Math.max(1, Math.min(9999, Math.floor(Number(v.page) || 1))), by: me.name, at: now() };
|
||||||
|
}
|
||||||
|
broadcast(p, { type: 'present', present: p.present });
|
||||||
|
} else if (m.type === 'pq' && isHost) {
|
||||||
|
const i = p.pq.findIndex((x) => x.id === m.id);
|
||||||
|
if (m.action === 'add') {
|
||||||
|
const url = String(m.url || '').trim();
|
||||||
|
if (!URL_RE.test(url) || p.pq.length >= MAX_QUEUE) return;
|
||||||
|
p.pq.push({ id: randomBytes(6).toString('base64url'), url, mode: m.mode === 'reader' ? 'reader' : 'embed', title: clean(m.title, 120) });
|
||||||
|
} else if (m.action === 'remove' && i >= 0) p.pq.splice(i, 1);
|
||||||
|
else if (m.action === 'move' && i >= 0) {
|
||||||
|
const j = Math.max(0, Math.min(p.pq.length - 1, i + (Number(m.by) < 0 ? -1 : 1)));
|
||||||
|
p.pq.splice(j, 0, p.pq.splice(i, 1)[0]);
|
||||||
|
} else return;
|
||||||
|
send(ws, { type: 'pq', items: p.pq });
|
||||||
|
} else if ((m.type === 'invite' || m.type === 'uninvite') && isHost) {
|
||||||
|
const name = String(m.profile || '').trim().toLowerCase();
|
||||||
|
if (!PROFILE_RE.test(name)) { send(ws, { type: 'invites', invites: inviteList(p), error: 'Profile names are 3–40 letters, digits, - or _' }); return; }
|
||||||
|
if (m.type === 'uninvite') { p.invites.delete(name); send(ws, { type: 'invites', invites: inviteList(p) }); return; }
|
||||||
|
if (p.invites.size >= MAX_INVITES) { send(ws, { type: 'invites', invites: inviteList(p), error: `At most ${MAX_INVITES} invites per party` }); return; }
|
||||||
|
Promise.resolve(profileExists(name)).catch(() => false).then((ok) => {
|
||||||
|
if (!parties.has(p.code)) return;
|
||||||
|
if (ok) p.invites.set(name, { at: now(), by: me.name });
|
||||||
|
send(ws, { type: 'invites', invites: inviteList(p), error: ok ? undefined : `No profile called “${name}”` });
|
||||||
|
});
|
||||||
} else if (m.type === 'settings' && isHost) {
|
} else if (m.type === 'settings' && isHost) {
|
||||||
p.allowControl = !!m.allowControl;
|
p.allowControl = !!m.allowControl;
|
||||||
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
||||||
@@ -141,6 +213,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
||||||
broadcast(p, { type: 'chat', msg });
|
broadcast(p, { type: 'chat', msg });
|
||||||
} else if (m.type === 'voice') {
|
} else if (m.type === 'voice') {
|
||||||
|
if (me.voice === !!m.on) return;
|
||||||
me.voice = !!m.on;
|
me.voice = !!m.on;
|
||||||
broadcast(p, { type: 'members', members: members(p) });
|
broadcast(p, { type: 'members', members: members(p) });
|
||||||
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
||||||
@@ -170,6 +243,8 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
upgrade,
|
upgrade,
|
||||||
|
invitesFor,
|
||||||
|
presentUrl: (code) => parties.get(code)?.present?.url || null,
|
||||||
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
|
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
|
||||||
_parties: parties,
|
_parties: parties,
|
||||||
stop() { clearInterval(sweeper); },
|
stop() { clearInterval(sweeper); },
|
||||||
|
|||||||
@@ -126,3 +126,124 @@ test('DJ suggestions and votes are broadcast, host consumes approved songs', asy
|
|||||||
h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]);
|
h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]);
|
||||||
h.ws.close(); g.ws.close();
|
h.ws.close(); g.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('watch party presenting', () => {
|
||||||
|
test('host shares a link that guests and late joiners see; junk and guest attempts are ignored', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0010', name: 'H' });
|
||||||
|
const { code } = await host.next('hello');
|
||||||
|
const g = client({ role: 'guest', pid: 'guestpid0011', key: 'k'.repeat(36), name: 'G', code });
|
||||||
|
await g.next('hello');
|
||||||
|
g.send({ type: 'present', present: { url: 'https://evil.example/' } });
|
||||||
|
host.send({ type: 'present', present: { url: 'javascript:alert(1)' } });
|
||||||
|
host.send({ type: 'present', present: { url: 'https://docs.google.com/presentation/d/abc/edit', page: 3 } });
|
||||||
|
const shown = await g.next('present');
|
||||||
|
expect(shown.present).toMatchObject({ url: 'https://docs.google.com/presentation/d/abc/edit', page: 3, by: 'H' });
|
||||||
|
const late = client({ role: 'guest', pid: 'guestpid0012', key: 'l'.repeat(36), name: 'L', code });
|
||||||
|
expect((await late.next('hello')).present.page).toBe(3);
|
||||||
|
host.send({ type: 'present', present: null });
|
||||||
|
expect((await g.next('present')).present).toBe(null);
|
||||||
|
host.send({ type: 'end' }); await host.closed;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watch party presentation queue', () => {
|
||||||
|
test('host prepares, reorders and removes queued presentations; guests cannot; it survives a host reload', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0030', name: 'H' });
|
||||||
|
const { code, secret } = await host.next('hello');
|
||||||
|
host.send({ type: 'pq', action: 'add', url: 'https://docs.google.com/presentation/d/a/edit', mode: 'embed', title: 'Sermon slides' });
|
||||||
|
host.send({ type: 'pq', action: 'add', url: 'https://example.com/notes', mode: 'reader', title: '' });
|
||||||
|
host.send({ type: 'pq', action: 'add', url: 'javascript:alert(1)' });
|
||||||
|
let items = (await host.next('pq')).items; items = (await host.next('pq')).items;
|
||||||
|
expect(items.map((x) => x.title || x.url)).toEqual(['Sermon slides', 'https://example.com/notes']);
|
||||||
|
host.send({ type: 'pq', action: 'move', id: items[1].id, by: -1 });
|
||||||
|
expect((await host.next('pq')).items[0].url).toBe('https://example.com/notes');
|
||||||
|
const g = client({ role: 'guest', pid: 'guestpid0031', key: 'q'.repeat(36), name: 'G', code });
|
||||||
|
expect((await g.next('hello')).pq).toBeUndefined();
|
||||||
|
g.send({ type: 'pq', action: 'add', url: 'https://evil.example/' });
|
||||||
|
host.ws.close(); await host.closed;
|
||||||
|
const again = client({ role: 'host', pid: 'hostpid0030', name: 'H', code, secret });
|
||||||
|
expect((await again.next('hello')).pq.length).toBe(2);
|
||||||
|
again.send({ type: 'pq', action: 'remove', id: items[0].id });
|
||||||
|
expect((await again.next('pq')).items.length).toBe(1);
|
||||||
|
again.send({ type: 'end' }); await again.closed;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watch party invites', () => {
|
||||||
|
test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });
|
||||||
|
const { code } = await host.next('hello');
|
||||||
|
const p = hub._parties.get(code);
|
||||||
|
host.send({ type: 'invite', profile: 'Psalm-Team' });
|
||||||
|
expect((await host.next('invites')).invites).toEqual(['psalm-team']);
|
||||||
|
expect(hub.invitesFor('PSALM-TEAM')).toMatchObject([{ code, host: 'Josh', members: 1 }]);
|
||||||
|
host.send({ type: 'invite', profile: 'x' });
|
||||||
|
expect((await host.next('invites')).error).toMatch(/3–40/);
|
||||||
|
const g = client({ role: 'guest', pid: 'guestpid0021', key: 'g'.repeat(36), name: 'G', code });
|
||||||
|
await g.next('hello');
|
||||||
|
g.send({ type: 'invite', profile: 'someone' });
|
||||||
|
await Bun.sleep(150);
|
||||||
|
expect([...p.invites.keys()]).toEqual(['psalm-team']);
|
||||||
|
host.send({ type: 'uninvite', profile: 'psalm-team' });
|
||||||
|
expect((await host.next('invites')).invites).toEqual([]);
|
||||||
|
expect(hub.invitesFor('psalm-team')).toEqual([]);
|
||||||
|
host.send({ type: 'end' }); await host.closed;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watch party seat rules', () => {
|
||||||
|
test('unknown code closes with 4004 (no reconnect loop)', async () => {
|
||||||
|
const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });
|
||||||
|
expect(await bad.closed).toBe(4004);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest cannot take the host seat or another guest seat; the same device can', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0002', name: 'H' });
|
||||||
|
const { code } = await host.next('hello');
|
||||||
|
const fake = client({ role: 'guest', pid: 'hostpid0002', name: 'Evil', code });
|
||||||
|
expect(await fake.closed).toBe(4003);
|
||||||
|
const g = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code });
|
||||||
|
await g.next('hello');
|
||||||
|
const thief = client({ role: 'guest', pid: 'guestpid0003', key: 'x'.repeat(36), name: 'T', code });
|
||||||
|
expect(await thief.closed).toBe(4005);
|
||||||
|
const again = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code });
|
||||||
|
await again.next('hello');
|
||||||
|
expect(await g.closed).toBe(4000);
|
||||||
|
host.send({ type: 'end' }); await host.closed;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a host resuming a room the server lost is told so', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0004', name: 'H', code: 'QQQQQQ', secret: 'whatever-secret' });
|
||||||
|
const hello = await host.next('hello');
|
||||||
|
expect(hello.lost).toBe(true);
|
||||||
|
expect(hello.code).not.toBe('QQQQQQ');
|
||||||
|
host.send({ type: 'end' }); await host.closed;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voice presence floods are dropped', async () => {
|
||||||
|
const host = client({ role: 'host', pid: 'hostpid0005', name: 'H' });
|
||||||
|
const { code } = await host.next('hello');
|
||||||
|
const g = client({ role: 'guest', pid: 'guestpid0006', key: 'k'.repeat(36), name: 'G', code });
|
||||||
|
await g.next('hello'); host.drain('members');
|
||||||
|
for (let i = 0; i < 400; i++) g.send({ type: 'voice', on: i % 2 === 0 });
|
||||||
|
await Bun.sleep(300);
|
||||||
|
let n = 0; try { for (;;) { await host.next('members', 50); n++; } } catch { /* drained */ }
|
||||||
|
expect(n).toBeLessThanOrEqual(41);
|
||||||
|
host.send({ type: 'end' }); await host.closed;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heartbeat replies only to the sender with the server clock', async () => {
|
||||||
|
const h = client({ role: 'host', pid: 'heartbeat-host', name: 'Host' });
|
||||||
|
const hello = await h.next('hello');
|
||||||
|
const g = client({ role: 'guest', pid: 'heartbeat-guest', code: hello.code, name: 'Guest' });
|
||||||
|
await g.next('hello');
|
||||||
|
const before = Date.now();
|
||||||
|
g.send({ type: 'ping', echo: 12345 });
|
||||||
|
const pong = await g.next('pong');
|
||||||
|
expect(pong.echo).toBe(12345);
|
||||||
|
expect(pong.now).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(pong.now).toBeLessThanOrEqual(Date.now());
|
||||||
|
await expect(h.next('pong', 50)).rejects.toThrow('timeout pong');
|
||||||
|
g.ws.close(); h.send({ type: 'end' }); await h.closed;
|
||||||
|
});
|
||||||
|
|||||||
256
server/profile-recovery.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// Password reset for protected profiles.
|
||||||
|
//
|
||||||
|
// A protected profile can store a recovery email and/or a Discord user id.
|
||||||
|
// "Forgot password" sends a one-time code there (Gmail SMTP / Discord bot DM);
|
||||||
|
// the code plus a new password replaces the profile's secret. A Discord
|
||||||
|
// webhook URL the user pastes needs no server setup; the other two channels
|
||||||
|
// are off until their credentials are in the environment:
|
||||||
|
// SMTP_USER, SMTP_PASS (Gmail app password), optional SMTP_HOST/PORT/FROM
|
||||||
|
// DISCORD_RELAY_URL + DISCORD_RELAY_TOKEN (discord-relay on the VPS), or DISCORD_BOT_TOKEN
|
||||||
|
//
|
||||||
|
// GET /api/profile/recovery?name= current contacts (needs the secret)
|
||||||
|
// POST /api/profile/recovery { name, current, email, discord, webhook }
|
||||||
|
// POST /api/profile/reset/request { name, via: 'email'|'discord'|'webhook' }
|
||||||
|
// POST /api/profile/reset/confirm { name, code, secret }
|
||||||
|
import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const CODE_TTL = 15 * 60_000, CODE_TRIES = 5;
|
||||||
|
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/;
|
||||||
|
const DISCORD_RE = /^\d{15,21}$/;
|
||||||
|
// Only Discord's own webhook endpoint, so a saved URL can never point the server elsewhere.
|
||||||
|
const WEBHOOK_RE = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/\d{15,21}\/[A-Za-z0-9_-]{30,100}$/;
|
||||||
|
const sha = (s) => createHash('sha256').update(s).digest();
|
||||||
|
const normCode = (s) => String(s || '').replace(/\D/g, '');
|
||||||
|
|
||||||
|
export function maskEmail(email) {
|
||||||
|
if (!email) return null;
|
||||||
|
const [user, domain] = String(email).split('@');
|
||||||
|
return `${user.slice(0, 1)}${'•'.repeat(Math.max(2, Math.min(6, user.length - 1)))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postWebhook(url, text) {
|
||||||
|
if (!WEBHOOK_RE.test(url)) throw new Error('not a Discord webhook URL');
|
||||||
|
const res = await fetch(`${url}?wait=true`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'YT Player', content: text, allowed_mentions: { parse: [] } }) });
|
||||||
|
if (res.status === 404 || res.status === 401) throw new Error('that Discord webhook no longer exists — save a new one under Recovery…');
|
||||||
|
if (!res.ok) throw new Error(`Discord refused the webhook message (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSenders(env = process.env) {
|
||||||
|
const senders = { webhook: postWebhook };
|
||||||
|
if (env.SMTP_USER && env.SMTP_PASS) {
|
||||||
|
let transport = null;
|
||||||
|
senders.email = async (to, subject, text) => {
|
||||||
|
if (!transport) {
|
||||||
|
const { default: nodemailer } = await import('nodemailer');
|
||||||
|
const port = Number(env.SMTP_PORT || 465);
|
||||||
|
transport = nodemailer.createTransport({ host: env.SMTP_HOST || 'smtp.gmail.com', port, secure: port === 465,
|
||||||
|
auth: { user: env.SMTP_USER, pass: String(env.SMTP_PASS).replace(/\s+/g, '') } });
|
||||||
|
}
|
||||||
|
await transport.sendMail({ from: env.SMTP_FROM || `YT Player <${env.SMTP_USER}>`, to, subject, text });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Discord DMs go through the discord-relay service on the VPS (it holds the
|
||||||
|
// bot token and the gateway connection); DISCORD_BOT_TOKEN talks to Discord
|
||||||
|
// directly instead.
|
||||||
|
if (env.DISCORD_RELAY_URL && env.DISCORD_RELAY_TOKEN) {
|
||||||
|
senders.discord = async (userId, text) => {
|
||||||
|
const res = await fetch(`${env.DISCORD_RELAY_URL.replace(/\/+$/, '')}/v1/dm`, {
|
||||||
|
method: 'POST', signal: AbortSignal.timeout(15000),
|
||||||
|
headers: { Authorization: `Bearer ${env.DISCORD_RELAY_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId, content: text }),
|
||||||
|
});
|
||||||
|
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `Discord relay answered ${res.status}`); }
|
||||||
|
};
|
||||||
|
} else if (env.DISCORD_BOT_TOKEN) {
|
||||||
|
senders.discord = async (userId, text) => {
|
||||||
|
const headers = { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' };
|
||||||
|
const ch = await fetch('https://discord.com/api/v10/users/@me/channels', { method: 'POST', headers, body: JSON.stringify({ recipient_id: userId }) });
|
||||||
|
if (!ch.ok) throw new Error(`Discord could not open a DM (${ch.status}) — the user must share a server with the bot`);
|
||||||
|
const { id } = await ch.json();
|
||||||
|
const msg = await fetch(`https://discord.com/api/v10/channels/${id}/messages`, { method: 'POST', headers, body: JSON.stringify({ content: text }) });
|
||||||
|
if (!msg.ok) throw new Error(`Discord refused the message (${msg.status}) — the user may block DMs from server members`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return senders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings saved from /admin (Notifications) live in app_settings; the
|
||||||
|
// environment still wins when it sets SMTP_USER.
|
||||||
|
const SETTING_KEYS = ['smtp_user', 'smtp_pass', 'smtp_from'];
|
||||||
|
|
||||||
|
export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders: fixedSenders = null,
|
||||||
|
adminAuth = null, env = process.env, now = Date.now, fetchImpl = fetch }) {
|
||||||
|
const ready = db.executeMultiple(`CREATE TABLE IF NOT EXISTS profile_resets (
|
||||||
|
name TEXT PRIMARY KEY, code_hash TEXT NOT NULL, via TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
||||||
|
let senders = fixedSenders || createSenders(env);
|
||||||
|
let saved = {};
|
||||||
|
const relayOn = !!(env.DISCORD_RELAY_URL && env.DISCORD_RELAY_TOKEN);
|
||||||
|
let relay = { checkedAt: 0, ok: false, bot_set: false, gateway: false, bot: '', error: '' };
|
||||||
|
async function relayCall(path, init = {}) {
|
||||||
|
const res = await fetchImpl(`${env.DISCORD_RELAY_URL.replace(/\/+$/, '')}${path}`, { ...init, signal: AbortSignal.timeout(10000),
|
||||||
|
headers: { Authorization: `Bearer ${env.DISCORD_RELAY_TOKEN}`, 'Content-Type': 'application/json' } });
|
||||||
|
return { status: res.status, json: await res.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
async function relayStatus(force = false) {
|
||||||
|
if (!relayOn || fixedSenders) return relay;
|
||||||
|
if (!force && now() - relay.checkedAt < 5 * 60_000) return relay;
|
||||||
|
try { const r = await relayCall('/v1/status'); relay = { ...relay, ...r.json, error: r.json.ok ? '' : (r.json.error || `relay ${r.status}`) }; }
|
||||||
|
catch (err) { relay = { ...relay, ok: false, gateway: false, error: `relay unreachable: ${err.message}` }; }
|
||||||
|
relay.checkedAt = now();
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
async function reload() {
|
||||||
|
if (fixedSenders) return;
|
||||||
|
await ready;
|
||||||
|
saved = Object.fromEntries((await db.execute('SELECT key, value FROM app_settings')).rows.map((r) => [r.key, r.value]));
|
||||||
|
const smtp = env.SMTP_USER ? {} : { SMTP_USER: saved.smtp_user || '', SMTP_PASS: saved.smtp_pass || '', SMTP_FROM: saved.smtp_from || '' };
|
||||||
|
senders = createSenders({ ...env, ...smtp });
|
||||||
|
}
|
||||||
|
const loaded = reload().catch((err) => console.error('[profile recovery] settings', err.message));
|
||||||
|
relayStatus(true).catch(() => {});
|
||||||
|
const sent = new Map(); // name → [timestamps] for request throttling
|
||||||
|
|
||||||
|
const channels = (row) => ({
|
||||||
|
email: !!(row?.recoveryEmail && senders.email),
|
||||||
|
discord: !!(row?.recoveryDiscord && senders.discord && (!relayOn || fixedSenders || relay.gateway)),
|
||||||
|
webhook: !!(row?.recoveryWebhook && senders.webhook),
|
||||||
|
emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null,
|
||||||
|
});
|
||||||
|
const body = async (c) => { try { return await c.req.json(); } catch { return null; } };
|
||||||
|
const nameOf = (v) => String(v || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
app.get('/api/profile/recovery', async (c) => {
|
||||||
|
await loaded;
|
||||||
|
const name = nameOf(c.req.query('name'));
|
||||||
|
const row = name ? await getProfile(name) : null;
|
||||||
|
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||||
|
const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); if (denied) return denied;
|
||||||
|
return c.json({ ok: true, email: row.recoveryEmail || '', discord: row.recoveryDiscord || '', webhook: row.recoveryWebhook || '',
|
||||||
|
available: { email: !!senders.email, discord: !!senders.discord, webhook: !!senders.webhook } }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/profile/recovery', async (c) => {
|
||||||
|
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
|
||||||
|
const name = nameOf(b.name);
|
||||||
|
const row = name ? await getProfile(name) : null;
|
||||||
|
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||||
|
if (!row.secretHash) return c.json({ ok: false, error: 'protect the profile first' }, 400);
|
||||||
|
const denied = await profileGate(c, name, row, b.current || c.req.header('x-profile-secret')); if (denied) return denied;
|
||||||
|
const email = String(b.email || '').trim().toLowerCase(), discord = String(b.discord || '').trim(), webhook = String(b.webhook || '').trim();
|
||||||
|
if (email && !EMAIL_RE.test(email)) return c.json({ ok: false, error: 'that email address does not look right' }, 400);
|
||||||
|
if (discord && !DISCORD_RE.test(discord)) return c.json({ ok: false, error: 'a Discord user id is 15–21 digits (Discord → Settings → Advanced → Developer Mode, then right-click your name → Copy User ID)' }, 400);
|
||||||
|
if (webhook && !WEBHOOK_RE.test(webhook)) return c.json({ ok: false, error: 'paste the full webhook URL from Discord (Channel settings → Integrations → Webhooks → Copy Webhook URL)' }, 400);
|
||||||
|
await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ?, recovery_webhook = ? WHERE name = ?', args: [email || null, discord || null, webhook || null, name] });
|
||||||
|
return c.json({ ok: true, email, discord, webhook });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/profile/reset/request', async (c) => {
|
||||||
|
await ready; await loaded; await relayStatus();
|
||||||
|
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
|
||||||
|
const name = nameOf(b.name), via = ['discord', 'webhook'].includes(b.via) ? b.via : 'email';
|
||||||
|
const row = name ? await getProfile(name) : null;
|
||||||
|
if (!row || !row.secretHash) return c.json({ ok: false, error: 'profile not found or not protected' }, 404);
|
||||||
|
if (!channels(row)[via]) return c.json({ ok: false, error: `no ${via === 'email' ? 'recovery email' : via === 'webhook' ? 'Discord webhook' : 'Discord account'} is set up for this profile` }, 400);
|
||||||
|
const t = now(), recent = (sent.get(name) || []).filter((x) => t - x < 3600_000);
|
||||||
|
if (recent.length && t - recent[recent.length - 1] < 60_000) return c.json({ ok: false, error: 'a code was just sent — wait a minute before asking again' }, 429);
|
||||||
|
if (recent.length >= 5) return c.json({ ok: false, error: 'too many reset codes this hour — try again later' }, 429);
|
||||||
|
const code = String(randomInt(0, 100_000_000)).padStart(8, '0');
|
||||||
|
const pretty = `${code.slice(0, 4)}-${code.slice(4)}`;
|
||||||
|
const text = `Your YT Player reset code for the profile "${name}" is ${pretty}. It expires in 15 minutes.\n\nIf you did not ask for this, ignore it — the password has not changed.`;
|
||||||
|
try {
|
||||||
|
if (via === 'email') await senders.email(row.recoveryEmail, `YT Player reset code: ${pretty}`, text);
|
||||||
|
else if (via === 'webhook') await senders.webhook(row.recoveryWebhook, text);
|
||||||
|
else await senders.discord(row.recoveryDiscord, text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[profile reset]', via, err.message);
|
||||||
|
return c.json({ ok: false, error: via === 'email' ? 'the email could not be sent — try again later' : err.message }, 502);
|
||||||
|
}
|
||||||
|
recent.push(t); sent.set(name, recent); if (sent.size > 5000) sent.clear();
|
||||||
|
await db.execute({ sql: `INSERT INTO profile_resets(name, code_hash, via, expires_at, attempts) VALUES(?,?,?,?,0)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET code_hash = excluded.code_hash, via = excluded.via, expires_at = excluded.expires_at, attempts = 0`,
|
||||||
|
args: [name, sha(code).toString('hex'), via, t + CODE_TTL] });
|
||||||
|
return c.json({ ok: true, via, hint: via === 'email' ? maskEmail(row.recoveryEmail) : via === 'webhook' ? 'your Discord channel' : 'your Discord DMs' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/profile/reset/confirm', async (c) => {
|
||||||
|
await ready;
|
||||||
|
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
|
||||||
|
const name = nameOf(b.name), code = normCode(b.code), secret = String(b.secret || '');
|
||||||
|
const reset = (await db.execute({ sql: 'SELECT * FROM profile_resets WHERE name = ?', args: [name] })).rows[0];
|
||||||
|
const drop = () => db.execute({ sql: 'DELETE FROM profile_resets WHERE name = ?', args: [name] });
|
||||||
|
if (!reset) return c.json({ ok: false, error: 'ask for a reset code first' }, 400);
|
||||||
|
if (Number(reset.expires_at) < now()) { await drop(); return c.json({ ok: false, error: 'that code has expired — ask for a new one' }, 400); }
|
||||||
|
if (Number(reset.attempts) >= CODE_TRIES) { await drop(); return c.json({ ok: false, error: 'too many wrong codes — ask for a new one' }, 429); }
|
||||||
|
const ok = code.length === 8 && timingSafeEqual(sha(code), Buffer.from(String(reset.code_hash), 'hex'));
|
||||||
|
if (!ok) {
|
||||||
|
await db.execute({ sql: 'UPDATE profile_resets SET attempts = attempts + 1 WHERE name = ?', args: [name] });
|
||||||
|
return c.json({ ok: false, wrongCode: true, error: `wrong code — ${CODE_TRIES - Number(reset.attempts) - 1} tries left` }, 401);
|
||||||
|
}
|
||||||
|
if (secret.length < 8 || secret.length > 200) return c.json({ ok: false, error: 'password must be 8–200 characters' }, 400);
|
||||||
|
await setProfileSecret(name, await Bun.password.hash(secret), 'password');
|
||||||
|
await drop();
|
||||||
|
return c.json({ ok: true, name, kind: 'password' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailState = () => ({
|
||||||
|
configured: !!senders.email, source: env.SMTP_USER ? 'environment' : saved.smtp_user ? 'admin' : null,
|
||||||
|
user: env.SMTP_USER || saved.smtp_user || '', from: env.SMTP_FROM || saved.smtp_from || '', passwordSet: !!(env.SMTP_PASS || saved.smtp_pass),
|
||||||
|
});
|
||||||
|
const discordState = () => ({ relay: relayOn, botSet: !!relay.bot_set, ready: !!relay.gateway, bot: relay.bot || '', error: relay.error || '' });
|
||||||
|
if (adminAuth) {
|
||||||
|
app.get('/api/admin/notify', adminAuth, async (c) => {
|
||||||
|
await loaded; await relayStatus(true);
|
||||||
|
return c.json({ ok: true, email: emailState(), discord: discordState(), webhook: true }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
app.put('/api/admin/notify', adminAuth, async (c) => {
|
||||||
|
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
|
||||||
|
await loaded;
|
||||||
|
if ('smtpUser' in b || 'smtpPass' in b || 'smtpFrom' in b) {
|
||||||
|
if (env.SMTP_USER) return c.json({ ok: false, error: 'SMTP is set in the server environment; change it in Dokploy instead' }, 409);
|
||||||
|
const user = String(b.smtpUser ?? saved.smtp_user ?? '').trim().toLowerCase();
|
||||||
|
const pass = 'smtpPass' in b && String(b.smtpPass || '').trim() ? String(b.smtpPass).replace(/\s+/g, '') : (saved.smtp_pass || '');
|
||||||
|
const from = String(b.smtpFrom ?? saved.smtp_from ?? '').trim().slice(0, 200);
|
||||||
|
if (user && !EMAIL_RE.test(user)) return c.json({ ok: false, error: 'the sending address does not look like an email' }, 400);
|
||||||
|
if (user && !pass) return c.json({ ok: false, error: 'add the Gmail app password (16 letters)' }, 400);
|
||||||
|
const values = { smtp_user: user, smtp_pass: user ? pass : '', smtp_from: user ? from : '' };
|
||||||
|
for (const k of SETTING_KEYS) {
|
||||||
|
if (values[k]) await db.execute({ sql: 'INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', args: [k, values[k]] });
|
||||||
|
else await db.execute({ sql: 'DELETE FROM app_settings WHERE key = ?', args: [k] });
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
if (b.discordBotToken) {
|
||||||
|
if (!relayOn) return c.json({ ok: false, error: 'the Discord relay is not configured on this server (DISCORD_RELAY_URL / DISCORD_RELAY_TOKEN)' }, 409);
|
||||||
|
try {
|
||||||
|
const r = await relayCall('/v1/bot-token', { method: 'PUT', body: JSON.stringify({ token: String(b.discordBotToken).trim() }) });
|
||||||
|
if (!r.json.ok) return c.json({ ok: false, error: r.json.error || `relay answered ${r.status}` }, 400);
|
||||||
|
} catch (err) { return c.json({ ok: false, error: `relay unreachable: ${err.message}` }, 502); }
|
||||||
|
}
|
||||||
|
await relayStatus(true);
|
||||||
|
return c.json({ ok: true, email: emailState(), discord: discordState() });
|
||||||
|
});
|
||||||
|
app.post('/api/admin/notify/test', adminAuth, async (c) => {
|
||||||
|
const b = await body(c) || {};
|
||||||
|
await loaded;
|
||||||
|
try {
|
||||||
|
if (b.via === 'discord') {
|
||||||
|
if (!senders.discord) throw new Error('Discord is not set up');
|
||||||
|
if (!DISCORD_RE.test(String(b.to || ''))) throw new Error('enter a Discord user id to send the test to');
|
||||||
|
await senders.discord(String(b.to), 'YT Player test message: Discord password-reset codes will arrive like this.');
|
||||||
|
} else {
|
||||||
|
if (!senders.email) throw new Error('email is not set up');
|
||||||
|
const to = String(b.to || '').trim();
|
||||||
|
if (!EMAIL_RE.test(to)) throw new Error('enter an email address to send the test to');
|
||||||
|
await senders.email(to, 'YT Player test email', 'Password-reset codes from YT Player will arrive like this.');
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err) { return c.json({ ok: false, error: err.message }, 400); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channels };
|
||||||
|
}
|
||||||
114
server/profile-recovery.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { expect, test, beforeAll } from 'bun:test';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-recovery-'));
|
||||||
|
process.env.DB_PATH = join(root, 'test.db');
|
||||||
|
const { initDb, db, createProfile, getProfile, setProfileSecret } = await import('./db.js');
|
||||||
|
const { registerProfileRecoveryRoutes, maskEmail, createSenders, postWebhook } = await import('./profile-recovery.js');
|
||||||
|
|
||||||
|
const outbox = [];
|
||||||
|
const senders = {
|
||||||
|
email: async (to, subject, text) => { outbox.push({ via: 'email', to, text }); },
|
||||||
|
discord: async (to, text) => { outbox.push({ via: 'discord', to, text }); },
|
||||||
|
webhook: async (to, text) => { outbox.push({ via: 'webhook', to, text }); },
|
||||||
|
};
|
||||||
|
const HOOK = 'https://discord.com/api/webhooks/123456789012345678/' + 'a'.repeat(68);
|
||||||
|
// Same contract as server.js profileGate: null when the secret opens the profile.
|
||||||
|
const profileGate = async (c, _name, row, provided) => {
|
||||||
|
if (!row.secretHash) return null;
|
||||||
|
return provided && await Bun.password.verify(String(provided), row.secretHash) ? null : c.json({ ok: false, needSecret: true }, 401);
|
||||||
|
};
|
||||||
|
let clock = 1_000_000;
|
||||||
|
const app = new Hono();
|
||||||
|
const recovery = registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders, now: () => clock });
|
||||||
|
const post = async (path, body) => { const r = await app.request(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
|
||||||
|
const codeFrom = (text) => text.match(/(\d{4})-(\d{4})/).slice(1).join('');
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initDb();
|
||||||
|
await createProfile('psalm', '{}');
|
||||||
|
await setProfileSecret('psalm', await Bun.password.hash('old-password'), 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery contacts need the current secret and valid values', async () => {
|
||||||
|
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'nope', email: 'a@b.co' })).status).toBe(401);
|
||||||
|
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'not-an-email' })).status).toBe(400);
|
||||||
|
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', discord: '12' })).status).toBe(400);
|
||||||
|
const ok = await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'Leader@Church.org', discord: '123456789012345678' });
|
||||||
|
expect(ok.json).toMatchObject({ ok: true, email: 'leader@church.org', discord: '123456789012345678' });
|
||||||
|
expect(recovery.channels(await getProfile('psalm'))).toEqual({ email: true, discord: true, webhook: false, emailHint: 'l•••••@church.org' });
|
||||||
|
for (const bad of ['https://evil.example/api/webhooks/123456789012345678/' + 'a'.repeat(68), 'http://discord.com/api/webhooks/123456789012345678/' + 'a'.repeat(68), HOOK + '/../x'])
|
||||||
|
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', webhook: bad })).status).toBe(400);
|
||||||
|
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'leader@church.org', discord: '123456789012345678', webhook: HOOK })).json.webhook).toBe(HOOK);
|
||||||
|
expect(recovery.channels(await getProfile('psalm')).webhook).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reset code sent by email replaces the password, once', async () => {
|
||||||
|
const sent = await post('/api/profile/reset/request', { name: 'psalm', via: 'email' });
|
||||||
|
expect(sent.json).toMatchObject({ ok: true, via: 'email' });
|
||||||
|
const mail = outbox.pop();
|
||||||
|
expect(mail.to).toBe('leader@church.org');
|
||||||
|
const code = codeFrom(mail.text);
|
||||||
|
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '00000000', secret: 'brand-new-pw' })).status).toBe(401);
|
||||||
|
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'short' })).status).toBe(400);
|
||||||
|
const done = await post('/api/profile/reset/confirm', { name: 'psalm', code: `${code.slice(0, 4)}-${code.slice(4)}`, secret: 'brand-new-pw' });
|
||||||
|
expect(done.json).toMatchObject({ ok: true, kind: 'password' });
|
||||||
|
expect(await Bun.password.verify('brand-new-pw', (await getProfile('psalm')).secretHash)).toBe(true);
|
||||||
|
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'another-one' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests are throttled, codes expire and run out of tries', async () => {
|
||||||
|
clock += 3600_000;
|
||||||
|
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).json.ok).toBe(true);
|
||||||
|
expect(outbox.pop()).toMatchObject({ via: 'discord', to: '123456789012345678' });
|
||||||
|
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).status).toBe(429);
|
||||||
|
for (let i = 0; i < 5; i++) await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' });
|
||||||
|
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' })).status).toBe(429);
|
||||||
|
clock += 61_000;
|
||||||
|
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'webhook' })).json.hint).toBe('your Discord channel');
|
||||||
|
const code = codeFrom(outbox.pop().text);
|
||||||
|
clock += 16 * 60_000;
|
||||||
|
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'brand-new-pw2' })).json.error).toMatch(/expired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unprotected profiles and unconfigured channels are refused', async () => {
|
||||||
|
await createProfile('open', '{}');
|
||||||
|
expect((await post('/api/profile/reset/request', { name: 'open', via: 'email' })).status).toBe(404);
|
||||||
|
expect(Object.keys(createSenders({}))).toEqual(['webhook']);
|
||||||
|
expect(Object.keys(createSenders({ SMTP_USER: 'a', SMTP_PASS: 'b', DISCORD_RELAY_URL: 'http://x', DISCORD_RELAY_TOKEN: 't' })).sort()).toEqual(['discord', 'email', 'webhook']);
|
||||||
|
await expect(postWebhook('https://example.com/x', 'hi')).rejects.toThrow(/not a Discord webhook/);
|
||||||
|
expect(maskEmail('a@b.co')).toBe('a••@b.co');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can save Gmail settings and forward a bot token to the relay', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeRelay = async (url, init = {}) => {
|
||||||
|
calls.push({ url, method: init.method || 'GET', auth: init.headers.Authorization, body: init.body });
|
||||||
|
const ready = calls.some((x) => x.method === 'PUT');
|
||||||
|
return new Response(JSON.stringify(url.endsWith('/v1/bot-token') && !JSON.parse(init.body).token.includes('.')
|
||||||
|
? { ok: false, error: 'that does not look like a Discord bot token' }
|
||||||
|
: { ok: true, bot_set: ready, gateway: ready, bot: ready ? 'YT Player' : '' }));
|
||||||
|
};
|
||||||
|
const admin = new Hono();
|
||||||
|
const env = { DISCORD_RELAY_URL: 'http://relay:8791/', DISCORD_RELAY_TOKEN: 'r'.repeat(64) };
|
||||||
|
registerProfileRecoveryRoutes(admin, { db, getProfile, setProfileSecret, profileGate, env, fetchImpl: fakeRelay, adminAuth: async (_c, next) => next() });
|
||||||
|
const call = async (path, method, body) => { const r = await admin.request(path, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
|
||||||
|
let s = await call('/api/admin/notify', 'GET');
|
||||||
|
expect(s.json.email).toMatchObject({ configured: false, source: null });
|
||||||
|
expect(s.json.discord).toMatchObject({ relay: true, botSet: false, ready: false });
|
||||||
|
expect((await call('/api/admin/notify', 'PUT', { smtpUser: 'sender@gmail.com' })).status).toBe(400);
|
||||||
|
s = await call('/api/admin/notify', 'PUT', { smtpUser: 'Sender@Gmail.com', smtpPass: 'abcd efgh ijkl mnop' });
|
||||||
|
expect(s.json.email).toMatchObject({ configured: true, source: 'admin', user: 'sender@gmail.com', passwordSet: true });
|
||||||
|
expect(JSON.stringify(s.json)).not.toContain('abcdefghijklmnop');
|
||||||
|
expect((await call('/api/admin/notify', 'PUT', { discordBotToken: 'junk' })).status).toBe(400);
|
||||||
|
s = await call('/api/admin/notify', 'PUT', { discordBotToken: 'MTIz.abcd.efgh' });
|
||||||
|
expect(s.json.discord).toMatchObject({ botSet: true, ready: true, bot: 'YT Player' });
|
||||||
|
expect(calls.every((x) => x.auth === `Bearer ${'r'.repeat(64)}` && x.url.startsWith('http://relay:8791/v1/'))).toBe(true);
|
||||||
|
expect((await call('/api/admin/notify/test', 'POST', { via: 'email', to: 'nope' })).json.error).toMatch(/email address/);
|
||||||
|
s = await call('/api/admin/notify', 'PUT', { smtpUser: '' });
|
||||||
|
expect(s.json.email.configured).toBe(false);
|
||||||
|
expect((await db.execute('SELECT COUNT(*) AS n FROM app_settings')).rows[0].n).toBe(0);
|
||||||
|
});
|
||||||
132
server/reader.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Reader view for watch-party presentations: fetch a public web page, pull
|
||||||
|
// out the article (Mozilla Readability) and return clean, safe HTML that every
|
||||||
|
// guest can read at their own font size.
|
||||||
|
//
|
||||||
|
// GET /api/party/:code/reader reads the link the party is presenting right now
|
||||||
|
//
|
||||||
|
// Only the party's current link can be fetched, so this is not an open proxy.
|
||||||
|
// The fetch refuses private/loopback addresses (also after every redirect),
|
||||||
|
// caps size and time, and the output keeps only an allow-list of tags and
|
||||||
|
// attributes.
|
||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { parseHTML } from 'linkedom';
|
||||||
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
|
const MAX_BYTES = 3 * 1024 * 1024, TIMEOUT_MS = 12000, MAX_REDIRECTS = 4;
|
||||||
|
const TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'em', 'strong', 'b', 'i', 'u', 's',
|
||||||
|
'a', 'img', 'figure', 'figcaption', 'br', 'hr', 'sup', 'sub', 'small', 'mark', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption', 'dl', 'dt', 'dd', 'span', 'div', 'section', 'article']);
|
||||||
|
const DROP = new Set(['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'noscript', 'svg', 'math', 'video', 'audio', 'canvas', 'link', 'meta', 'base', 'template']);
|
||||||
|
|
||||||
|
export function privateAddress(ip) {
|
||||||
|
const v = isIP(ip);
|
||||||
|
if (v === 4) {
|
||||||
|
const [a, b] = ip.split('.').map(Number);
|
||||||
|
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254)
|
||||||
|
|| (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
|
||||||
|
}
|
||||||
|
if (v === 6) {
|
||||||
|
const x = ip.toLowerCase();
|
||||||
|
if (x.startsWith('::ffff:')) return privateAddress(x.slice(7));
|
||||||
|
return x === '::' || x === '::1' || x.startsWith('fc') || x.startsWith('fd') || x.startsWith('fe8') || x.startsWith('fe9')
|
||||||
|
|| x.startsWith('fea') || x.startsWith('feb') || x.startsWith('ff');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPublic(u, resolve) {
|
||||||
|
if (!/^https?:$/.test(u.protocol)) throw new Error('only http and https links can be read');
|
||||||
|
if (u.port && !['80', '443'].includes(u.port)) throw new Error('that port is not allowed');
|
||||||
|
if (u.username || u.password) throw new Error('links with credentials are not allowed');
|
||||||
|
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const addrs = isIP(host) ? [host] : (await resolve(host)).map((a) => a.address);
|
||||||
|
if (!addrs.length || addrs.some(privateAddress)) throw new Error('that address is not on the public internet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublic(url, { fetchImpl, resolve }) {
|
||||||
|
let u = new URL(url);
|
||||||
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||||
|
await assertPublic(u, resolve);
|
||||||
|
const res = await fetchImpl(u.href, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; YTPlayerReader/1.0)', Accept: 'text/html,application/xhtml+xml' } });
|
||||||
|
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { u = new URL(res.headers.get('location'), u); continue; }
|
||||||
|
if (!res.ok) throw new Error(`the site answered ${res.status}`);
|
||||||
|
const type = res.headers.get('content-type') || '';
|
||||||
|
if (!/html|xml/i.test(type)) throw new Error('that link is not a web page (try Original view)');
|
||||||
|
const reader = res.body.getReader(); const parts = []; let size = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read(); if (done) break;
|
||||||
|
size += value.length; if (size > MAX_BYTES) { reader.cancel(); throw new Error('that page is too large to read'); }
|
||||||
|
parts.push(value);
|
||||||
|
}
|
||||||
|
const charset = (type.match(/charset=([\w-]+)/i) || [])[1] || 'utf-8';
|
||||||
|
let text; try { text = new TextDecoder(charset).decode(Buffer.concat(parts)); } catch { text = new TextDecoder().decode(Buffer.concat(parts)); }
|
||||||
|
return { html: text, finalUrl: u.href };
|
||||||
|
}
|
||||||
|
throw new Error('too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the article from an allow-list; anything else is unwrapped or dropped.
|
||||||
|
export function sanitize(html, baseUrl) {
|
||||||
|
const { document } = parseHTML(`<!doctype html><html><body><div id="r">${html}</div></body></html>`);
|
||||||
|
const abs = (v) => { try { const x = new URL(v, baseUrl); return /^https?:$/.test(x.protocol) ? x.href : null; } catch { return null; } };
|
||||||
|
const walk = (node) => {
|
||||||
|
for (const child of [...node.childNodes]) {
|
||||||
|
if (child.nodeType === 8) { child.remove(); continue; }
|
||||||
|
if (child.nodeType !== 1) continue;
|
||||||
|
const tag = child.tagName.toLowerCase();
|
||||||
|
if (DROP.has(tag)) { child.remove(); continue; }
|
||||||
|
walk(child);
|
||||||
|
if (!TAGS.has(tag)) { child.replaceWith(...child.childNodes); continue; }
|
||||||
|
const keep = {};
|
||||||
|
if (tag === 'a') { const h = abs(child.getAttribute('href') || ''); if (h) Object.assign(keep, { href: h, target: '_blank', rel: 'noopener noreferrer nofollow' }); }
|
||||||
|
if (tag === 'img') {
|
||||||
|
const src = abs(child.getAttribute('src') || child.getAttribute('data-src') || '');
|
||||||
|
if (!src) { child.remove(); continue; }
|
||||||
|
Object.assign(keep, { src, alt: (child.getAttribute('alt') || '').slice(0, 300), loading: 'lazy', referrerpolicy: 'no-referrer' });
|
||||||
|
}
|
||||||
|
if ((tag === 'td' || tag === 'th') && /^\d{1,2}$/.test(child.getAttribute('colspan') || '')) keep.colspan = child.getAttribute('colspan');
|
||||||
|
for (const a of [...child.attributes]) child.removeAttribute(a.name);
|
||||||
|
for (const [k, v] of Object.entries(keep)) child.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const root = document.getElementById('r');
|
||||||
|
walk(root);
|
||||||
|
return root.innerHTML.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractArticle(html, url) {
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
const article = new Readability(document, { charThreshold: 300 }).parse();
|
||||||
|
if (!article || !article.content) throw new Error('could not find readable text on that page (try Original view)');
|
||||||
|
return {
|
||||||
|
title: String(article.title || '').trim().slice(0, 300), byline: String(article.byline || '').trim().slice(0, 200),
|
||||||
|
site: String(article.siteName || new URL(url).hostname.replace(/^www\./, '')).slice(0, 120),
|
||||||
|
excerpt: String(article.excerpt || '').slice(0, 500), content: sanitize(article.content, url),
|
||||||
|
length: Number(article.length) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerReaderRoutes(app, { presentUrlOf, fetchImpl = fetch, resolve = (h) => lookup(h, { all: true }), now = Date.now }) {
|
||||||
|
const cache = new Map(); // url → { at, value }
|
||||||
|
const hits = new Map(); // ip → timestamps
|
||||||
|
const ipOf = (c) => (c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '').split(',')[0].trim() || 'local';
|
||||||
|
app.get('/api/party/:code/reader', async (c) => {
|
||||||
|
const url = presentUrlOf(String(c.req.param('code') || '').toUpperCase());
|
||||||
|
if (!url) return c.json({ ok: false, error: 'this party is not presenting a link' }, 404);
|
||||||
|
const t = now(), ip = ipOf(c), mine = (hits.get(ip) || []).filter((x) => t - x < 60_000);
|
||||||
|
if (mine.length >= 30) return c.json({ ok: false, error: 'too many requests — try again in a minute' }, 429);
|
||||||
|
mine.push(t); hits.set(ip, mine); if (hits.size > 5000) hits.clear();
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached && t - cached.at < 10 * 60_000) return c.json({ ok: true, ...cached.value }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
try {
|
||||||
|
const page = await fetchPublic(url, { fetchImpl, resolve });
|
||||||
|
const value = { url, ...extractArticle(page.html, page.finalUrl) };
|
||||||
|
cache.set(url, { at: t, value });
|
||||||
|
if (cache.size > 60) cache.delete(cache.keys().next().value);
|
||||||
|
return c.json({ ok: true, ...value }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.name === 'TimeoutError' ? 'the site took too long to answer' : err.message }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
49
server/reader.test.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { privateAddress, sanitize, extractArticle, registerReaderRoutes } from './reader.js';
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html><html><head><title>Psalm 23 study</title></head><body>
|
||||||
|
<nav>menu menu menu</nav>
|
||||||
|
<article><h1>Psalm 23 study</h1><p class="by">By Ana</p>
|
||||||
|
${'<p>The Lord is my shepherd; I shall not want. He makes me lie down in green pastures. He leads me beside still waters. </p>'.repeat(8)}
|
||||||
|
<p>Read <a href="/next" onclick="steal()">the next part</a> <img src="/pic.jpg" onerror="x()"> <script>alert(1)</script></p>
|
||||||
|
<iframe src="https://evil.example"></iframe><a href="javascript:alert(1)">bad</a></article></body></html>`;
|
||||||
|
|
||||||
|
test('private and special addresses are refused', () => {
|
||||||
|
for (const ip of ['127.0.0.1', '10.8.0.1', '192.168.1.2', '172.20.0.1', '169.254.169.254', '100.64.0.1', '::1', 'fd00::1', '::ffff:10.0.0.1', '0.0.0.0'])
|
||||||
|
expect(privateAddress(ip)).toBe(true);
|
||||||
|
for (const ip of ['93.184.216.34', '1.1.1.1', '2606:4700::1111']) expect(privateAddress(ip)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitize keeps reading tags, makes links absolute and strips scripts/handlers', () => {
|
||||||
|
const out = sanitize('<p style="x" onclick="a()">Hi <a href="/x">link</a><script>bad()</script><img src="i.png" onerror="y()"><iframe></iframe><custom>kept text</custom></p>', 'https://site.example/a/b');
|
||||||
|
expect(out).toMatch(/<a [^>]*href="https:\/\/site\.example\/x"[^>]*>link<\/a>/);
|
||||||
|
expect(out).toContain('rel="noopener noreferrer nofollow"');
|
||||||
|
expect(out).toContain('src="https://site.example/a/i.png"');
|
||||||
|
expect(out).toContain('kept text');
|
||||||
|
for (const bad of ['script', 'onclick', 'onerror', 'iframe', 'style=', 'custom']) expect(out).not.toContain(bad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractArticle finds the article and drops unsafe parts', () => {
|
||||||
|
const a = extractArticle(PAGE, 'https://bible.example/psalm-23');
|
||||||
|
expect(a.title).toBe('Psalm 23 study');
|
||||||
|
expect(a.content).toContain('The Lord is my shepherd');
|
||||||
|
expect(a.content).toContain('href="https://bible.example/next"');
|
||||||
|
for (const bad of ['<script', 'onclick', 'onerror', 'javascript:', 'iframe']) expect(a.content).not.toContain(bad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the route reads only the presented link, refuses private targets and redirects into them', async () => {
|
||||||
|
const present = { ABCDEF: 'https://bible.example/psalm-23', PRIVAT: 'http://10.0.0.5/admin', HOPPER: 'https://hop.example/' };
|
||||||
|
const resolve = async (h) => [{ address: h === 'inside.example' ? '192.168.0.9' : '93.184.216.34' }];
|
||||||
|
const fetchImpl = async (url) => url.startsWith('https://hop.example')
|
||||||
|
? new Response('', { status: 302, headers: { location: 'http://inside.example/secret' } })
|
||||||
|
: new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
||||||
|
const app = new Hono();
|
||||||
|
registerReaderRoutes(app, { presentUrlOf: (code) => present[code], fetchImpl, resolve });
|
||||||
|
const get = async (code) => { const r = await app.request(`/api/party/${code}/reader`); return { status: r.status, json: await r.json() }; };
|
||||||
|
expect((await get('ZZZZZZ')).status).toBe(404);
|
||||||
|
const ok = await get('abcdef');
|
||||||
|
expect(ok.json).toMatchObject({ ok: true, title: 'Psalm 23 study' });
|
||||||
|
expect((await get('PRIVAT')).json.error).toMatch(/public internet/);
|
||||||
|
expect((await get('HOPPER')).json.error).toMatch(/public internet/);
|
||||||
|
});
|
||||||
@@ -77,7 +77,17 @@ export async function recommend(fingerprint = '', limit = 12) {
|
|||||||
|
|
||||||
// Capture cards from successful discovery responses, including future routes.
|
// Capture cards from successful discovery responses, including future routes.
|
||||||
// Incoming profile/sync payloads also carry cards absent from the response.
|
// Incoming profile/sync payloads also carry cards absent from the response.
|
||||||
export function registerCatalogRoutes(app, { resolveListener = async (c, name, fp) => name ? c.json({ ok: false, error: 'profile unavailable' }, 401) : fp } = {}) {
|
export function registerCatalogRoutes(app, { resolveListener = async (c, name, fp) => name ? c.json({ ok: false, error: 'profile unavailable' }, 401) : fp,
|
||||||
|
adminAuth = async c => c.json({ ok: false, error: 'admin unavailable' }, 503) } = {}) {
|
||||||
|
// Anonymous submissions: a per-IP budget, and they may only add videos or fill blanks.
|
||||||
|
const collectLog = new Map();
|
||||||
|
const overCollectBudget = c => {
|
||||||
|
const ip = (c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '').split(',')[0].trim() || 'local';
|
||||||
|
const now = Date.now(), list = (collectLog.get(ip) || []).filter(t => now - t < 10 * 60_000);
|
||||||
|
list.push(now); collectLog.set(ip, list);
|
||||||
|
if (collectLog.size > 5000) collectLog.clear();
|
||||||
|
return list.length > 30;
|
||||||
|
};
|
||||||
app.use('/api/*', async (c, next) => {
|
app.use('/api/*', async (c, next) => {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
const incoming = c.req.method === 'POST' && /^\/api\/(user\/sync|profile\/(create|save)|playlist\/share)$/.test(path)
|
const incoming = c.req.method === 'POST' && /^\/api\/(user\/sync|profile\/(create|save)|playlist\/share)$/.test(path)
|
||||||
@@ -87,7 +97,11 @@ export function registerCatalogRoutes(app, { resolveListener = async (c, name, f
|
|||||||
const discovery = c.req.method === 'GET' && !/^\/api\/admin\/(analytics|metadata|collections)(\/|$)/.test(path) && !path.startsWith('/api/catalog/') && path !== '/api/recommendations';
|
const discovery = c.req.method === 'GET' && !/^\/api\/admin\/(analytics|metadata|collections)(\/|$)/.test(path) && !path.startsWith('/api/catalog/') && path !== '/api/recommendations';
|
||||||
try {
|
try {
|
||||||
if (discovery && c.res.headers.get('content-type')?.includes('application/json')) {
|
if (discovery && c.res.headers.get('content-type')?.includes('application/json')) {
|
||||||
const result = await c.res.clone().json();
|
// /api/search answers gzipped; read the body decompressed.
|
||||||
|
const res = c.res.clone();
|
||||||
|
const result = res.headers.get('content-encoding') === 'gzip'
|
||||||
|
? JSON.parse(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await res.arrayBuffer()))))
|
||||||
|
: await res.json();
|
||||||
if (typeof result.data === 'string') { try { result.data = JSON.parse(result.data); } catch { /* invalid profile */ } }
|
if (typeof result.data === 'string') { try { result.data = JSON.parse(result.data); } catch { /* invalid profile */ } }
|
||||||
await ingest(extractCards(result), path.includes('search') ? 'search-cache' : path.includes('channel') ? 'channel' : path.includes('streams') ? 'streams' : 'playlist');
|
await ingest(extractCards(result), path.includes('search') ? 'search-cache' : path.includes('channel') ? 'channel' : path.includes('streams') ? 'streams' : 'playlist');
|
||||||
}
|
}
|
||||||
@@ -112,13 +126,26 @@ export function registerCatalogRoutes(app, { resolveListener = async (c, name, f
|
|||||||
}
|
}
|
||||||
catch (e) { console.warn('[recommendations]', e.message); return c.json({ ok: false, error: 'Recommendations are unavailable right now.' }, 503); }
|
catch (e) { console.warn('[recommendations]', e.message); return c.json({ ok: false, error: 'Recommendations are unavailable right now.' }, 503); }
|
||||||
});
|
});
|
||||||
app.post('/api/catalog/collect', async c => {
|
async function readCards(c, maxBytes, maxCards) {
|
||||||
if (Number(c.req.header('content-length')) > 512000) return c.json({ ok: false, error: 'metadata too large' }, 413);
|
if (Number(c.req.header('content-length')) > maxBytes) return c.json({ ok: false, error: 'metadata too large' }, 413);
|
||||||
let body;
|
let body;
|
||||||
try { const raw = await c.req.text(); if (raw.length > 512000) return c.json({ ok: false, error: 'metadata too large' }, 413); body = JSON.parse(raw); }
|
try { const raw = await c.req.text(); if (raw.length > maxBytes) return c.json({ ok: false, error: 'metadata too large' }, 413); body = JSON.parse(raw); }
|
||||||
catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||||
if (!Array.isArray(body.cards) || body.cards.length > 500) return c.json({ ok: false, error: 'expected at most 500 cards' }, 400);
|
if (!Array.isArray(body?.cards) || body.cards.length > maxCards) return c.json({ ok: false, error: `expected at most ${maxCards} cards` }, 400);
|
||||||
try { return c.json({ ok: true, collected: await ingest(body.cards, 'client-search') }); }
|
return body.cards;
|
||||||
|
}
|
||||||
|
app.post('/api/catalog/collect', async c => {
|
||||||
|
if (overCollectBudget(c)) return c.json({ ok: false, error: 'too many submissions — try again later' }, 429);
|
||||||
|
const cards = await readCards(c, 512000, 500);
|
||||||
|
if (!Array.isArray(cards)) return cards;
|
||||||
|
try { return c.json({ ok: true, collected: await ingest(cards, 'client-search', { fillMissing: true }) }); }
|
||||||
|
catch { return c.json({ ok: false, error: 'Could not save metadata.' }, 503); }
|
||||||
|
});
|
||||||
|
// Bulk import (channel/playlist listings): admin login or API token; may overwrite metadata.
|
||||||
|
app.post('/api/admin/catalog/import', adminAuth, async c => {
|
||||||
|
const cards = await readCards(c, 4 * 1024 * 1024, 5000);
|
||||||
|
if (!Array.isArray(cards)) return cards;
|
||||||
|
try { return c.json({ ok: true, collected: await ingest(cards, 'collector') }); }
|
||||||
catch { return c.json({ ok: false, error: 'Could not save metadata.' }, 503); }
|
catch { return c.json({ ok: false, error: 'Could not save metadata.' }, 503); }
|
||||||
});
|
});
|
||||||
app.get('/api/catalog/:id/thumbnail', async c => {
|
app.get('/api/catalog/:id/thumbnail', async c => {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ test('client cached search collection validates input and stores new metadata',
|
|||||||
expect(picks.results).toHaveLength(4);
|
expect(picks.results).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('anonymous collection only fills blanks, is rate limited, and bulk import needs admin', async () => {
|
||||||
|
const deny = async c => c.json({ ok: false }, 401);
|
||||||
|
const allow = async (c, next) => c.req.header('x-test-admin') === 'yes' ? next() : deny(c);
|
||||||
|
const app = new Hono(); registerCatalogRoutes(app, { adminAuth: allow });
|
||||||
|
const card = { ...b, id: 'fillonlyvid', title: 'Original title' };
|
||||||
|
const ip = { 'X-Forwarded-For': '203.0.113.9' };
|
||||||
|
const post = (path, body, headers = {}) => app.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
|
||||||
|
expect((await post('/api/catalog/collect', { cards: [card] }, ip)).status).toBe(200);
|
||||||
|
await post('/api/catalog/collect', { cards: [{ ...card, title: 'Spam title' }] }, ip);
|
||||||
|
const title = async () => JSON.parse((await db.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [card.id] })).rows[0].card).title;
|
||||||
|
expect(await title()).toBe('Original title');
|
||||||
|
for (let i = 0; i < 28; i++) await post('/api/catalog/collect', { cards: [] }, ip);
|
||||||
|
expect((await post('/api/catalog/collect', { cards: [] }, ip)).status).toBe(429);
|
||||||
|
expect((await post('/api/catalog/collect', { cards: [] }, { 'X-Forwarded-For': '203.0.113.10' })).status).toBe(200);
|
||||||
|
expect((await post('/api/admin/catalog/import', { cards: [{ ...card, title: 'Fixed title' }] })).status).toBe(401);
|
||||||
|
expect(await (await post('/api/admin/catalog/import', { cards: [{ ...card, title: 'Fixed title' }] }, { 'x-test-admin': 'yes' })).json()).toEqual({ ok: true, collected: 1 });
|
||||||
|
expect(await title()).toBe('Fixed title');
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy backfill progresses with a durable cursor and finds unplayed search metadata', async () => {
|
test('legacy backfill progresses with a durable cursor and finds unplayed search metadata', async () => {
|
||||||
const card = { ...b, id: 'legacyvideo' };
|
const card = { ...b, id: 'legacyvideo' };
|
||||||
await cache.put('legacy', [card]);
|
await cache.put('legacy', [card]);
|
||||||
|
|||||||
92
server/request-monitor.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { statfs } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const routeKey = (path) => path
|
||||||
|
.replace(/\/api\/media\/[^/]+/g, '/api/media/:id')
|
||||||
|
.replace(/\/api\/download\/[^/]+/g, '/api/download/:id')
|
||||||
|
.replace(/\/api\/admin\/uploads\/[^/]+/g, '/api/admin/uploads/:id')
|
||||||
|
.replace(/\?.*$/, '');
|
||||||
|
const percentile = (values, p) => values.length ? values[Math.min(values.length - 1, Math.ceil(values.length * p) - 1)] : 0;
|
||||||
|
|
||||||
|
export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adapters = {}, now = () => Date.now(), snapshotIntervalMs = 60_000 } = {}) {
|
||||||
|
const routes = new Map();
|
||||||
|
const snapshots = [];
|
||||||
|
let inflight = 0;
|
||||||
|
let lastCpu = process.cpuUsage();
|
||||||
|
let lastCpuAt = now();
|
||||||
|
let snapshotTimer;
|
||||||
|
|
||||||
|
async function sample() {
|
||||||
|
const cpuNow = process.cpuUsage();
|
||||||
|
const elapsed = Math.max(1, now() - lastCpuAt) * 1000;
|
||||||
|
const cpuPct = Math.min(100 * (cpuNow.user + cpuNow.system - lastCpu.user - lastCpu.system) / elapsed, 100);
|
||||||
|
lastCpu = cpuNow; lastCpuAt = now();
|
||||||
|
const volumes = await Promise.all(diskPaths.map(async ({ name, path }) => {
|
||||||
|
try { const fs = await statfs(path); return { name, freeBytes: Number(fs.bavail) * Number(fs.bsize), totalBytes: Number(fs.blocks) * Number(fs.bsize) }; }
|
||||||
|
catch { return { name, unavailable: true }; }
|
||||||
|
}));
|
||||||
|
const routeMetrics = Object.fromEntries([...routes].map(([key, v]) => {
|
||||||
|
const sorted = [...v.latencies].sort((a, b) => a - b);
|
||||||
|
return [key, { p50: percentile(sorted, .5), p95: percentile(sorted, .95), p99: percentile(sorted, .99), requests: v.requests, errors: v.errors, errorRate: v.requests ? v.errors / v.requests : 0 }];
|
||||||
|
}));
|
||||||
|
const [ytdlp, media, transcription, cache] = await Promise.all([
|
||||||
|
adapters.ytdlp?.() ?? null, adapters.media?.() ?? null, adapters.transcription?.() ?? null, adapters.cache?.() ?? null,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
at: now(), routes: routeMetrics, inflight, rssBytes: process.memoryUsage().rss, cpuPercent: cpuPct,
|
||||||
|
ytdlp, media, transcription, cache, volumes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshot() {
|
||||||
|
const value = await sample(); snapshots.push(value);
|
||||||
|
while (snapshots.length && now() - snapshots[0].at > 24 * 3600_000) snapshots.shift();
|
||||||
|
if (snapshotFile) {
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(snapshotFile), { recursive: true });
|
||||||
|
const prior = (await readFile(snapshotFile, 'utf8').catch(() => '')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= now() - 24 * 3600_000);
|
||||||
|
const unique = new Map([...prior, value].map(item => [item.at, item]));
|
||||||
|
await writeFile(snapshotFile, [...unique.values()].sort((a,b)=>a.at-b.at).map(item => JSON.stringify(item)).join('\n') + '\n');
|
||||||
|
}
|
||||||
|
catch (error) { console.warn('[monitor] snapshot write failed:', error.message); }
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function history() {
|
||||||
|
const cutoff = now() - 24 * 3600_000;
|
||||||
|
const out = snapshots.filter(x => x.at >= cutoff);
|
||||||
|
if (snapshotFile) {
|
||||||
|
try {
|
||||||
|
const persisted = (await readFile(snapshotFile, 'utf8')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= cutoff);
|
||||||
|
const unique = new Map([...persisted, ...out].map(item => [item.at, item]));
|
||||||
|
return [...unique.values()].sort((a,b)=>a.at-b.at).slice(-1440);
|
||||||
|
} catch { /* no file yet */ }
|
||||||
|
}
|
||||||
|
return out.slice(-1440);
|
||||||
|
}
|
||||||
|
|
||||||
|
function middleware() {
|
||||||
|
return async (c, next) => {
|
||||||
|
const started = now(); inflight++;
|
||||||
|
let status = 500;
|
||||||
|
try { await next(); status = c.res.status; }
|
||||||
|
finally {
|
||||||
|
inflight--;
|
||||||
|
let key = routeKey(c.req.path);
|
||||||
|
if (!routes.has(key) && routes.size >= 300) key = 'other';
|
||||||
|
let row = routes.get(key);
|
||||||
|
if (!row) { row = { latencies: [], requests: 0, errors: 0 }; routes.set(key, row); }
|
||||||
|
row.requests++; if (status >= 400) row.errors++;
|
||||||
|
row.latencies.push(Math.max(0, now() - started)); if (row.latencies.length > 1000) row.latencies.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshotIntervalMs > 0) { snapshotTimer = setInterval(() => snapshot().catch(() => {}), snapshotIntervalMs); snapshotTimer.unref?.(); }
|
||||||
|
return { middleware, sample, snapshot, history, close: () => clearInterval(snapshotTimer) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRequestMonitorRoutes(app, { adminAuth, monitor }) {
|
||||||
|
app.get('/api/admin/ops/metrics', adminAuth, async c => c.json({ ok: true, current: await monitor.sample(), history: await monitor.history() }, 200, { 'Cache-Control': 'no-store' }));
|
||||||
|
}
|
||||||
34
server/request-monitor.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRequestMonitor } from './request-monitor.js';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { registerRequestMonitorRoutes } from './request-monitor.js';
|
||||||
|
|
||||||
|
test('request monitor records route latency, status errors, and current process health', async () => {
|
||||||
|
let t = 100;
|
||||||
|
const monitor = createRequestMonitor({ now: () => t, snapshotIntervalMs: 0, adapters: { media: () => ({ queued: 2 }) } });
|
||||||
|
const middleware = monitor.middleware();
|
||||||
|
const ctx = (path, status) => ({ req: { path }, res: { status } });
|
||||||
|
await middleware(ctx('/api/media/abc/status', 200), async () => { t += 8; });
|
||||||
|
await middleware(ctx('/api/media/abc/status', 404), async () => { t += 4; });
|
||||||
|
await middleware(ctx('/api/media/xyz/status', 503), async () => { t += 12; });
|
||||||
|
const sample = await monitor.sample();
|
||||||
|
assert.equal(sample.routes['/api/media/:id/status'].requests, 3);
|
||||||
|
assert.equal(sample.routes['/api/media/:id/status'].errors, 2);
|
||||||
|
assert.equal(sample.routes['/api/media/:id/status'].p50, 8);
|
||||||
|
assert.equal(sample.media.queued, 2);
|
||||||
|
assert.ok(sample.rssBytes > 0);
|
||||||
|
monitor.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitor snapshot history is retained and its endpoint is admin protected', async () => {
|
||||||
|
let t = 10_000;
|
||||||
|
const monitor = createRequestMonitor({ now: () => t, snapshotIntervalMs: 0 });
|
||||||
|
await monitor.snapshot(); t += 60_000; await monitor.snapshot();
|
||||||
|
const app = new Hono();
|
||||||
|
registerRequestMonitorRoutes(app, { monitor, adminAuth: async (c,next) => c.req.header('x-admin') ? next() : c.json({ok:false},401) });
|
||||||
|
assert.equal((await app.request('/api/admin/ops/metrics')).status, 401);
|
||||||
|
const response = await app.request('/api/admin/ops/metrics', { headers: { 'x-admin': 'yes' } });
|
||||||
|
const data = await response.json(); assert.equal(data.history.length, 2); assert.ok(data.current.rssBytes > 0);
|
||||||
|
monitor.close();
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// repeat query — from any device, or after a restart — never waits on YouTube.
|
// repeat query — from any device, or after a restart — never waits on YouTube.
|
||||||
// Capped by row count (default 250,000 queries) AND bytes (default 2 GiB), LRU.
|
// Capped by row count (default 250,000 queries) AND bytes (default 2 GiB), LRU.
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { ingest, withLocalThumbnails, trimCatalog } from './video-catalog.js';
|
import { ingest, withLocalThumbnails, trimCatalog, foldSearchText } from './video-catalog.js';
|
||||||
|
|
||||||
export const MAX_ROWS = Number(process.env.SEARCH_CACHE_MAX_ROWS) || 250_000;
|
export const MAX_ROWS = Number(process.env.SEARCH_CACHE_MAX_ROWS) || 250_000;
|
||||||
export const MAX_BYTES = Number(process.env.SEARCH_CACHE_MAX_BYTES) || 2 * 1024 ** 3;
|
export const MAX_BYTES = Number(process.env.SEARCH_CACHE_MAX_BYTES) || 2 * 1024 ** 3;
|
||||||
@@ -70,15 +70,28 @@ export async function rememberVideos(cards) {
|
|||||||
|
|
||||||
// Cards whose title/channel contain EVERY word of the query, most-seen first.
|
// Cards whose title/channel contain EVERY word of the query, most-seen first.
|
||||||
export async function searchVideos(q, limit = 60) {
|
export async function searchVideos(q, limit = 60) {
|
||||||
const words = String(q).toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
|
const folded = foldSearchText(q).trim();
|
||||||
|
const words = folded.split(/\s+/).filter(Boolean).slice(0, 8);
|
||||||
if (!words.length) return [];
|
if (!words.length) return [];
|
||||||
const like = (w) => '%' + w.replace(/[\\%_]/g, '\\$&') + '%';
|
const like = (w) => '%' + w.replace(/[\\%_]/g, '\\$&') + '%';
|
||||||
const r = await db.execute({
|
const r = await db.execute({
|
||||||
sql: `SELECT card FROM video_meta WHERE ${words.map(() => "hay LIKE ? ESCAPE '\\'").join(' AND ')}
|
sql: `SELECT card,seen FROM video_meta WHERE ${words.map(() => "hay LIKE ? ESCAPE '\\'").join(' AND ')}
|
||||||
ORDER BY seen DESC, updated_at DESC LIMIT ?`,
|
ORDER BY seen DESC, updated_at DESC LIMIT ?`,
|
||||||
args: [...words.map(like), limit],
|
args: [...words.map(like), Math.max(limit * 10, 200)],
|
||||||
});
|
});
|
||||||
return withLocalThumbnails(r.rows.map((x) => { try { return JSON.parse(x.card); } catch { return null; } }).filter(Boolean));
|
const titleHasAll = title => words.every(word => foldSearchText(title).includes(word));
|
||||||
|
const phrase = folded;
|
||||||
|
const ranked = r.rows.map(row => {
|
||||||
|
try {
|
||||||
|
const card = JSON.parse(row.card);
|
||||||
|
const title = foldSearchText(card.title);
|
||||||
|
const channel = foldSearchText(card.channel);
|
||||||
|
const score = (title === phrase || title.includes(phrase) ? 4 : 0) +
|
||||||
|
(titleHasAll(card.title) ? 2 : 0) + (words.some(word => channel.includes(word)) ? 1 : 0);
|
||||||
|
return { card: { ...card, url: card.url || `https://www.youtube.com/watch?v=${card.id}` }, score, seen: Number(row.seen) || 0 };
|
||||||
|
} catch { return null; }
|
||||||
|
}).filter(Boolean).sort((a, b) => b.score - a.score || b.seen - a.seen).slice(0, limit).map(x => x.card);
|
||||||
|
return withLocalThumbnails(ranked);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trimVideos(max = MAX_VIDEOS) {
|
export async function trimVideos(max = MAX_VIDEOS) {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { test, expect } from 'bun:test';
|
import { test, expect, afterAll } from 'bun:test';
|
||||||
import * as sc from './search-cache.js';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { initDb } from './db.js';
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-search-cache-'));
|
||||||
|
process.env.DB_PATH = join(root, 'test.db');
|
||||||
|
const sc = await import('./search-cache.js');
|
||||||
|
const { initDb } = await import('./db.js');
|
||||||
await initDb();
|
await initDb();
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
const cards = (n) => Array.from({ length: n }, (_, i) => ({ id: 'v' + i, title: 't' + i }));
|
const cards = (n) => Array.from({ length: n }, (_, i) => ({ id: 'v' + i, title: 't' + i }));
|
||||||
|
|
||||||
test('put/get round-trips, case-insensitive, capped at 200', async () => {
|
test('put/get round-trips, case-insensitive, capped at 200', async () => {
|
||||||
@@ -21,11 +26,11 @@ test('trim evicts least-recently-used rows over the row cap', async () => {
|
|||||||
|
|
||||||
test('known videos are remembered and found by every word', async () => {
|
test('known videos are remembered and found by every word', async () => {
|
||||||
await sc.rememberVideos([
|
await sc.rememberVideos([
|
||||||
{ id: 'a1', title: 'Way Maker - Sinach', channel: 'Worship' },
|
{ id: 'aaa11111111', title: 'Way Maker - Sinach', channel: 'Worship' },
|
||||||
{ id: 'b2', title: 'Way Maker (live)', channel: 'Other' },
|
{ id: 'bbb22222222', title: 'Way Maker (live)', channel: 'Other' },
|
||||||
{ id: 'c3', title: 'Goodness of God', channel: 'Worship' },
|
{ id: 'ccc33333333', title: 'Goodness of God', channel: 'Worship' },
|
||||||
]);
|
]);
|
||||||
expect((await sc.searchVideos('way maker')).map((c) => c.id).sort()).toEqual(['a1', 'b2']);
|
expect((await sc.searchVideos('way maker')).map((c) => c.id).sort()).toEqual(['aaa11111111', 'bbb22222222']);
|
||||||
expect((await sc.searchVideos('maker worship')).map((c) => c.id)).toEqual(['a1']);
|
expect((await sc.searchVideos('maker worship')).map((c) => c.id)).toEqual(['aaa11111111']);
|
||||||
expect(await sc.searchVideos('100%')).toEqual([]);
|
expect(await sc.searchVideos('100%')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
202
server/server.js
@@ -47,6 +47,7 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
|
|||||||
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
|
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
|
||||||
import * as notesDb from './db.js';
|
import * as notesDb from './db.js';
|
||||||
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
|
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
|
||||||
|
import { registerAdminToolsRoutes } from './admin-tools.js';
|
||||||
import { createRemoteHub } from './remote.js';
|
import { createRemoteHub } from './remote.js';
|
||||||
import { createPartyHub } from './party.js';
|
import { createPartyHub } from './party.js';
|
||||||
import { registerMediaMetaRoutes } from './media-meta.js';
|
import { registerMediaMetaRoutes } from './media-meta.js';
|
||||||
@@ -63,12 +64,17 @@ import { sha256Range } from './hash.js';
|
|||||||
import * as innertube from './innertube.js';
|
import * as innertube from './innertube.js';
|
||||||
import { createRelatedService, registerRelatedRoute } from './related.js';
|
import { createRelatedService, registerRelatedRoute } from './related.js';
|
||||||
import * as searchCacheDb from './search-cache.js';
|
import * as searchCacheDb from './search-cache.js';
|
||||||
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails } from './video-catalog.js';
|
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails, backfillStep } from './video-catalog.js';
|
||||||
import { registerCatalogRoutes } from './recommendations.js';
|
import { registerCatalogRoutes } from './recommendations.js';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { createYtdlpPool } from './ytdlp-pool.js';
|
import { createYtdlpPool } from './ytdlp-pool.js';
|
||||||
import { createWarmQueue } from './warm-queue.js';
|
import { createWarmQueue } from './warm-queue.js';
|
||||||
|
import { registerDeepSearchRoutes } from './deep-search.js';
|
||||||
|
import { registerProfileRecoveryRoutes } from './profile-recovery.js';
|
||||||
|
import { registerReaderRoutes } from './reader.js';
|
||||||
import { dirname, join as pathJoin } from 'node:path';
|
import { dirname, join as pathJoin } from 'node:path';
|
||||||
|
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
|
||||||
|
import { registerP2pAdminRoutes } from './p2p-admin.js';
|
||||||
|
|
||||||
// A media proxy must not die because one client's stream hit an edge case
|
// A media proxy must not die because one client's stream hit an edge case
|
||||||
// (see /api/play cancel()): log and keep serving instead of crash-looping.
|
// (see /api/play cancel()): log and keep serving instead of crash-looping.
|
||||||
@@ -268,6 +274,7 @@ function slimEntry(j) {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: pick(j, 'title') || '(untitled)',
|
title: pick(j, 'title') || '(untitled)',
|
||||||
|
url: /^[\w-]{11}$/.test(id) ? `https://www.youtube.com/watch?v=${id}` : null,
|
||||||
channel: pickChannel(j),
|
channel: pickChannel(j),
|
||||||
channelId: pickChannelId(j),
|
channelId: pickChannelId(j),
|
||||||
channelUrl: pickChannelUrl(j),
|
channelUrl: pickChannelUrl(j),
|
||||||
@@ -343,7 +350,24 @@ function channelToUrl(c) {
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.use('*', logger());
|
app.use('*', logger());
|
||||||
registerCatalogRoutes(app, { resolveListener: recommendationListener });
|
const requestMonitor = createRequestMonitor({
|
||||||
|
snapshotFile: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'ops-metrics.jsonl'),
|
||||||
|
snapshotIntervalMs: process.env.OPS_MONITOR === '0' ? 0 : 60_000,
|
||||||
|
diskPaths: [{ name: 'app-data', path: dirname(process.env.DB_PATH || './data/ytplayer.db') }, { name: 'media', path: process.env.MEDIA_DIR || './data/media' }],
|
||||||
|
adapters: {
|
||||||
|
ytdlp: () => ytdlpPool ? ytdlpPool.stats() : null,
|
||||||
|
media: () => media?.stats ? media.stats() : null,
|
||||||
|
transcription: async () => {
|
||||||
|
try { const rows = (await notesDb.db.execute("SELECT status,COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running') GROUP BY status")).rows;
|
||||||
|
const counts = Object.fromEntries(rows.map(row => [row.status, Number(row.n)])); return { queued: counts.queued || 0, active: counts.running || 0 };
|
||||||
|
} catch { return null; }
|
||||||
|
},
|
||||||
|
cache: () => ({ ...searchCacheCounters, memoryEntries: searchCache.size }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (process.env.OPS_MONITOR !== '0') app.use('*', requestMonitor.middleware());
|
||||||
|
registerCatalogRoutes(app, { resolveListener: recommendationListener,
|
||||||
|
adminAuth: (c, next) => notes.requireAdminOrToken(c, next) }); // notes is registered further down
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API routes
|
// API routes
|
||||||
@@ -380,17 +404,31 @@ const SEARCH_CACHE_TTL_MS = 10 * 60_000; // fresh: answered from memory
|
|||||||
const SEARCH_CACHE_STALE_MS = 15 * 24 * 3600_000; // persistent copy: stale-while-revalidate for 15 days
|
const SEARCH_CACHE_STALE_MS = 15 * 24 * 3600_000; // persistent copy: stale-while-revalidate for 15 days
|
||||||
const searchCache = new Map(); // lowercased q -> { yt, fetchedAt } (YouTube cards only)
|
const searchCache = new Map(); // lowercased q -> { yt, fetchedAt } (YouTube cards only)
|
||||||
const inflightSearches = new Map(); // lowercased q -> Promise<{ yt, youtubeError? }>
|
const inflightSearches = new Map(); // lowercased q -> Promise<{ yt, youtubeError? }>
|
||||||
|
const inflightProgress = new Map(); // lowercased q -> { cards, done, waiters } while pages are still arriving
|
||||||
|
const searchCacheCounters = { hits: 0, misses: 0 };
|
||||||
|
|
||||||
// The YouTube side of one search (up to 200 cards), shared by every caller asking
|
// The YouTube side of one search (up to 200 cards), shared by every caller asking
|
||||||
// the same thing at the same time, and written to memory + the persistent cache.
|
// the same thing at the same time, and written to memory + the persistent cache.
|
||||||
|
// Resolves as soon as the first InnerTube page arrives (~20 cards, under a
|
||||||
|
// second); the remaining pages keep loading in the background and land in the
|
||||||
|
// caches, so the next ask for the same query gets the full list. Paging all
|
||||||
|
// 200 cards before answering took 7-12 s and timed out on slow phone links.
|
||||||
function fetchYoutube(q) {
|
function fetchYoutube(q) {
|
||||||
const key = q.toLowerCase();
|
const key = q.toLowerCase();
|
||||||
if (inflightSearches.has(key)) return inflightSearches.get(key);
|
if (inflightSearches.has(key)) return inflightSearches.get(key);
|
||||||
const p = (async () => {
|
let answered = false, resolveEarly;
|
||||||
|
const early = new Promise((r) => { resolveEarly = r; });
|
||||||
|
const progress = { cards: [], done: false, waiters: new Set() };
|
||||||
|
const wake = () => { for (const w of progress.waiters) w(); progress.waiters.clear(); };
|
||||||
|
inflightProgress.set(key, progress);
|
||||||
|
const full = (async () => {
|
||||||
let yt = [];
|
let yt = [];
|
||||||
if (process.env.SEARCH_INNERTUBE !== '0') {
|
if (process.env.SEARCH_INNERTUBE !== '0') {
|
||||||
try {
|
try {
|
||||||
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY });
|
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY, onPage: (cards) => {
|
||||||
|
progress.cards = cards; wake();
|
||||||
|
if (!answered && cards.length) { answered = true; resolveEarly({ yt: cards, fetchedAt: Date.now(), partial: true }); }
|
||||||
|
} });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`);
|
console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -404,12 +442,17 @@ function fetchYoutube(q) {
|
|||||||
yt = parseCards(out);
|
yt = parseCards(out);
|
||||||
}
|
}
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value);
|
// An empty answer is a transient failure, never a result worth caching.
|
||||||
searchCache.set(key, { yt, fetchedAt });
|
if (yt.length) {
|
||||||
if (yt.length) await searchCacheDb.rememberVideos(yt);
|
if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value);
|
||||||
if (yt.length) searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
|
searchCache.set(key, { yt, fetchedAt });
|
||||||
|
await searchCacheDb.rememberVideos(yt);
|
||||||
|
searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
|
||||||
|
}
|
||||||
return { yt, fetchedAt };
|
return { yt, fetchedAt };
|
||||||
})().finally(() => inflightSearches.delete(key));
|
})().finally(() => { inflightSearches.delete(key); progress.done = true; wake(); setTimeout(() => { if (inflightProgress.get(key) === progress) inflightProgress.delete(key); }, 30_000); });
|
||||||
|
full.catch((e) => { if (answered) console.warn(`[search] background fill: ${e.message}`); });
|
||||||
|
const p = Promise.race([early, full]);
|
||||||
inflightSearches.set(key, p);
|
inflightSearches.set(key, p);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@@ -418,6 +461,24 @@ const searchLibrary = async (q) => {
|
|||||||
// This server's own library first — it still answers when YouTube is unreachable.
|
// This server's own library first — it still answers when YouTube is unreachable.
|
||||||
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
|
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
|
||||||
};
|
};
|
||||||
|
// Ids the server holds a ready copy of, refreshed at most once a minute, so
|
||||||
|
// search results can say "On server" without a query per card.
|
||||||
|
let serverCopies = { at: 0, ids: new Set(), loading: null };
|
||||||
|
async function serverCopyIds() {
|
||||||
|
if (Date.now() - serverCopies.at < 60_000) return serverCopies.ids;
|
||||||
|
serverCopies.loading ||= notesDb.db.execute("SELECT video_id FROM media_cache WHERE status = 'ready'")
|
||||||
|
.then((r) => { serverCopies = { at: Date.now(), ids: new Set(r.rows.map((x) => x.video_id)), loading: null }; })
|
||||||
|
.catch(() => { serverCopies.loading = null; serverCopies.at = Date.now(); });
|
||||||
|
await serverCopies.loading;
|
||||||
|
return serverCopies.ids;
|
||||||
|
}
|
||||||
|
let copyIds = new Set();
|
||||||
|
const withYoutubeUrl = card => {
|
||||||
|
if (!card) return card;
|
||||||
|
const out = /^[\w-]{11}$/.test(card.id || '') ? { ...card, url: `https://www.youtube.com/watch?v=${card.id}` } : { ...card };
|
||||||
|
if (copyIds.has(card.id) || String(card.id || '').startsWith('upl_')) out.onServer = true;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/search/local?q=<query> — videos this server already knows (from every
|
// GET /api/search/local?q=<query> — videos this server already knows (from every
|
||||||
// earlier search) plus its own uploads; answers in milliseconds so the app can
|
// earlier search) plus its own uploads; answers in milliseconds so the app can
|
||||||
@@ -425,8 +486,9 @@ const searchLibrary = async (q) => {
|
|||||||
app.get('/api/search/local', async (c) => {
|
app.get('/api/search/local', async (c) => {
|
||||||
const q = (c.req.query('q') || '').trim();
|
const q = (c.req.query('q') || '').trim();
|
||||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||||
const [mine, known] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => [])]);
|
const [mine, known, ids] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => []), serverCopyIds()]);
|
||||||
return c.json({ ok: true, results: [...mine, ...known], local: true });
|
copyIds = ids;
|
||||||
|
return c.json({ ok: true, results: [...mine, ...known].map(withYoutubeUrl), local: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/search?q=<query>[&refresh=1]
|
// GET /api/search?q=<query>[&refresh=1]
|
||||||
@@ -439,7 +501,25 @@ app.get('/api/search', async (c) => {
|
|||||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||||
const key = q.toLowerCase();
|
const key = q.toLowerCase();
|
||||||
const mine = await searchLibrary(q);
|
const mine = await searchLibrary(q);
|
||||||
const answer = (yt, extra = {}) => c.json({ ok: true, results: [...mine, ...yt], ...extra });
|
copyIds = await serverCopyIds();
|
||||||
|
const answer = (yt, extra = {}) => {
|
||||||
|
const body = JSON.stringify({ ok: true, results: [...mine, ...yt].map(withYoutubeUrl), ...extra });
|
||||||
|
const headers = { 'Content-Type': 'application/json; charset=utf-8', Vary: 'Accept-Encoding' };
|
||||||
|
if (/\bgzip\b/.test(c.req.header('accept-encoding') || '') && body.length > 1024)
|
||||||
|
return new Response(Bun.gzipSync(body), { headers: { ...headers, 'Content-Encoding': 'gzip' } });
|
||||||
|
return new Response(body, { headers });
|
||||||
|
};
|
||||||
|
// after=N: the app shows N results and the rest are still being paged in.
|
||||||
|
// Answer as soon as there are more (≈ one page per second), or after 4 s;
|
||||||
|
// `partial` stays true until the last page is in.
|
||||||
|
const after = Number(c.req.query('after'));
|
||||||
|
if (Number.isFinite(after) && after > 0 && inflightProgress.has(key)) {
|
||||||
|
const pr = inflightProgress.get(key);
|
||||||
|
if (!pr.done && mine.length + pr.cards.length <= after) {
|
||||||
|
await new Promise((resolve) => { const t = setTimeout(resolve, 4000); pr.waiters.add(() => { clearTimeout(t); resolve(); }); });
|
||||||
|
}
|
||||||
|
return answer(pr.cards, pr.done ? { fetchedAt: Date.now() } : { fetchedAt: Date.now(), partial: true });
|
||||||
|
}
|
||||||
if (c.req.query('refresh') !== '1') {
|
if (c.req.query('refresh') !== '1') {
|
||||||
let hit = searchCache.get(key);
|
let hit = searchCache.get(key);
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
@@ -447,15 +527,18 @@ app.get('/api/search', async (c) => {
|
|||||||
if (row) { hit = { yt: row.results, fetchedAt: row.fetchedAt }; searchCache.set(key, hit); }
|
if (row) { hit = { yt: row.results, fetchedAt: row.fetchedAt }; searchCache.set(key, hit); }
|
||||||
}
|
}
|
||||||
const age = hit ? Date.now() - hit.fetchedAt : Infinity;
|
const age = hit ? Date.now() - hit.fetchedAt : Infinity;
|
||||||
if (hit && age < SEARCH_CACHE_TTL_MS) return answer(hit.yt, { fetchedAt: hit.fetchedAt });
|
if (hit && age < SEARCH_CACHE_TTL_MS) { searchCacheCounters.hits++; return answer(hit.yt, { fetchedAt: hit.fetchedAt }); }
|
||||||
if (hit && age < SEARCH_CACHE_STALE_MS) {
|
if (hit && age < SEARCH_CACHE_STALE_MS) {
|
||||||
|
searchCacheCounters.hits++;
|
||||||
fetchYoutube(q).catch(() => { /* keep serving the stale copy */ });
|
fetchYoutube(q).catch(() => { /* keep serving the stale copy */ });
|
||||||
return answer(hit.yt, { stale: true, fetchedAt: hit.fetchedAt });
|
return answer(hit.yt, { stale: true, fetchedAt: hit.fetchedAt });
|
||||||
}
|
}
|
||||||
|
searchCacheCounters.misses++;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { yt, fetchedAt } = await fetchYoutube(q);
|
const { yt, fetchedAt, partial } = await fetchYoutube(q);
|
||||||
return answer(yt, { fetchedAt });
|
if (!yt.length && !mine.length) return c.json({ ok: false, error: 'YouTube returned no results right now, try again' }, 503);
|
||||||
|
return answer(yt, partial ? { fetchedAt, partial: true } : { fetchedAt });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
|
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
|
||||||
return c.json({ ok: false, error: err.message }, 500);
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
@@ -1101,7 +1184,7 @@ const SAVE_DEFAULT_FORMAT =
|
|||||||
// again) share ONE yt-dlp run instead of spawning another each time.
|
// again) share ONE yt-dlp run instead of spawning another each time.
|
||||||
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
|
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
|
||||||
|
|
||||||
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
|
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal, sourceUrl = null) {
|
||||||
await assertNotLive(videoId);
|
await assertNotLive(videoId);
|
||||||
const key = videoId + '|' + formatArgs.join(' ');
|
const key = videoId + '|' + formatArgs.join(' ');
|
||||||
let entry = inflightDownloads.get(key);
|
let entry = inflightDownloads.get(key);
|
||||||
@@ -1110,10 +1193,11 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
|
|||||||
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
|
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
|
||||||
const ctl = new AbortController();
|
const ctl = new AbortController();
|
||||||
entry = { waiters: 0, tmpBase, ctl };
|
entry = { waiters: 0, tmpBase, ctl };
|
||||||
entry.promise = withSaveSlot(() => {
|
entry.promise = withSaveSlot(async () => {
|
||||||
if (ctl.signal.aborted) throw new Error('save cancelled');
|
if (ctl.signal.aborted) throw new Error('save cancelled');
|
||||||
|
const url = sourceUrl || await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`;
|
||||||
return runYtdlpResilient([
|
return runYtdlpResilient([
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
url,
|
||||||
'--no-warnings', '--no-playlist',
|
'--no-warnings', '--no-playlist',
|
||||||
...formatArgs,
|
...formatArgs,
|
||||||
'--limit-rate', DOWNLOAD_RATE,
|
'--limit-rate', DOWNLOAD_RATE,
|
||||||
@@ -1183,14 +1267,16 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal, cachedSrc
|
|||||||
try {
|
try {
|
||||||
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams
|
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams
|
||||||
// — unless the server cache already holds it.
|
// — unless the server cache already holds it.
|
||||||
if (!cachedSrc) await withSaveSlot(() => runYtdlpResilient([
|
if (!cachedSrc) await withSaveSlot(async () => {
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
const url = await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`;
|
||||||
'--no-warnings', '--no-playlist',
|
return runYtdlpResilient([
|
||||||
'-f', SAVE_MUX_FORMAT,
|
url, '--no-warnings', '--no-playlist',
|
||||||
'--merge-output-format', 'mp4',
|
'-f', SAVE_MUX_FORMAT,
|
||||||
'--limit-rate', DOWNLOAD_RATE,
|
'--merge-output-format', 'mp4',
|
||||||
'-o', srcTmp,
|
'--limit-rate', DOWNLOAD_RATE,
|
||||||
], { signal }));
|
'-o', srcTmp,
|
||||||
|
], { signal });
|
||||||
|
});
|
||||||
// 2) Trim + concat the keep segments into the final custom video.
|
// 2) Trim + concat the keep segments into the final custom video.
|
||||||
await runFfmpeg([
|
await runFfmpeg([
|
||||||
'-y', '-hide_banner', '-loglevel', 'error',
|
'-y', '-hide_banner', '-loglevel', 'error',
|
||||||
@@ -1240,6 +1326,7 @@ if (!MEDIA_VOLUME_MARKER || existsSync(MEDIA_VOLUME_MARKER)) mkdirSync(MEDIA_DIR
|
|||||||
const media = createMediaCache({
|
const media = createMediaCache({
|
||||||
dir: MEDIA_DIR,
|
dir: MEDIA_DIR,
|
||||||
volumeMarker: MEDIA_VOLUME_MARKER,
|
volumeMarker: MEDIA_VOLUME_MARKER,
|
||||||
|
getSourceUrl: videoCatalog.getSourceUrl,
|
||||||
db: {
|
db: {
|
||||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
||||||
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
||||||
@@ -1248,9 +1335,9 @@ const media = createMediaCache({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
getInfo: async (videoId) => (await resolveStreams(videoId)).info,
|
getInfo: async (videoId) => (await resolveStreams(videoId)).info,
|
||||||
download: async (videoId, out) => {
|
download: async (videoId, out, sourceUrl) => {
|
||||||
await withSaveSlot(() => runYtdlpResilient([
|
await withSaveSlot(() => runYtdlpResilient([
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
sourceUrl || `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
'--no-warnings', '--no-playlist',
|
'--no-warnings', '--no-playlist',
|
||||||
'-f', SAVE_MUX_FORMAT,
|
'-f', SAVE_MUX_FORMAT,
|
||||||
'--merge-output-format', 'mp4',
|
'--merge-output-format', 'mp4',
|
||||||
@@ -1587,6 +1674,28 @@ registerMediaMetaRoutes(app, { db: notesDb, catalog: videoCatalog, media });
|
|||||||
|
|
||||||
// POST /api/media/:id/redownload — the client's "Broken" button. Idempotent:
|
// POST /api/media/:id/redownload — the client's "Broken" button. Idempotent:
|
||||||
// a job already queued/running for this id is just reported back.
|
// a job already queued/running for this id is just reported back.
|
||||||
|
// POST /api/media/prefetch { ids: [...] } — queue server copies of many videos
|
||||||
|
// (e.g. an imported playlist) at background priority. Already-ready ids are
|
||||||
|
// reported, not refetched; the usual duration/disk limits still apply.
|
||||||
|
const prefetchHits = new Map(); // ip → timestamps
|
||||||
|
app.post('/api/media/prefetch', async (c) => {
|
||||||
|
let body; try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||||
|
const ip = clientIpOf(c.req.raw, null) || 'local';
|
||||||
|
const t = Date.now(), recent = (prefetchHits.get(ip) || []).filter((x) => t - x < 10 * 60_000);
|
||||||
|
if (recent.length >= 10) return c.json({ ok: false, error: 'too many download requests — try again in a few minutes' }, 429);
|
||||||
|
recent.push(t); prefetchHits.set(ip, recent); if (prefetchHits.size > 5000) prefetchHits.clear();
|
||||||
|
const ids = [...new Set((Array.isArray(body?.ids) ? body.ids : []).map(String).filter((id) => media.isMediaId(id) && !id.startsWith('upl_')))].slice(0, 500);
|
||||||
|
if (!ids.length) return c.json({ ok: false, error: 'no YouTube video ids in the request' }, 400);
|
||||||
|
let ready = 0, queued = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (await media.getReady(id).catch(() => null)) { ready++; continue; }
|
||||||
|
queued++;
|
||||||
|
media.ensureCached(id, { priority: LOW, auto: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
serverCopies.at = 0; // the On server badges refresh on the next search
|
||||||
|
return c.json({ ok: true, requested: ids.length, ready, queued });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/media/:id/redownload', async (c) => {
|
app.post('/api/media/:id/redownload', async (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) });
|
return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) });
|
||||||
@@ -1599,6 +1708,10 @@ app.post('/api/media/:id/redownload', async (c) => {
|
|||||||
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
||||||
// the ETag pins the generation, so a resumed save never mixes two copies.
|
// the ETag pins the generation, so a resumed save never mixes two copies.
|
||||||
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
||||||
|
app.post('/api/diag', async (c) => {
|
||||||
|
try { const t = (await c.req.text()).slice(0, 20000); console.log('[diag]', t); } catch {}
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
registerDownloadRetryRoute(app, {
|
registerDownloadRetryRoute(app, {
|
||||||
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||||
});
|
});
|
||||||
@@ -1827,10 +1940,16 @@ app.get('/api/profile/info', async (c) => {
|
|||||||
const name = (c.req.query('name') || '').trim().toLowerCase();
|
const name = (c.req.query('name') || '').trim().toLowerCase();
|
||||||
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
||||||
const row = await getProfile(name);
|
const row = await getProfile(name);
|
||||||
return c.json({ ok: true, exists: !!row, protected: !!(row && row.secretHash), kind: row ? row.secretKind : null },
|
return c.json({ ok: true, exists: !!row, protected: !!(row && row.secretHash), kind: row ? row.secretKind : null,
|
||||||
|
recovery: row && row.secretHash ? profileRecovery.channels(row) : null },
|
||||||
200, { 'Cache-Control': 'no-store' });
|
200, { 'Cache-Control': 'no-store' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// adminAuth is wired in once notes.js is registered (further down).
|
||||||
|
const recoveryAdmin = { auth: null };
|
||||||
|
const profileRecovery = registerProfileRecoveryRoutes(app, { db: notesDb.db, getProfile, setProfileSecret, profileGate,
|
||||||
|
adminAuth: (c, next) => (recoveryAdmin.auth ? recoveryAdmin.auth(c, next) : c.json({ ok: false, error: 'admin unavailable' }, 503)) });
|
||||||
|
|
||||||
// POST /api/profile/secret
|
// POST /api/profile/secret
|
||||||
// Body: { name, current?, secret: <new password/key> | null, kind: 'password'|'key' }
|
// Body: { name, current?, secret: <new password/key> | null, kind: 'password'|'key' }
|
||||||
// Adds, changes or (secret: null) removes protection. Changing or removing an
|
// Adds, changes or (secret: null) removes protection. Changing or removing an
|
||||||
@@ -2243,7 +2362,17 @@ app.get('/api/user/data', async (c) => {
|
|||||||
// Phone remote for a desktop instance — see remote.js
|
// Phone remote for a desktop instance — see remote.js
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
||||||
const party = createPartyHub();
|
const party = createPartyHub({ profileExists: async (name) => !!(await getProfile(name)) });
|
||||||
|
registerReaderRoutes(app, { presentUrlOf: (code) => party.presentUrl(code) });
|
||||||
|
// Running parties that invited this online profile (protected profiles need their secret).
|
||||||
|
app.get('/api/party/invites', async (c) => {
|
||||||
|
const name = (c.req.query('name') || '').trim().toLowerCase();
|
||||||
|
const row = name ? await getProfile(name) : null;
|
||||||
|
if (!row) return c.json({ ok: true, invites: [] }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
const denied = await profileGate(c, name, row, c.req.header('x-profile-secret'));
|
||||||
|
if (denied) return denied;
|
||||||
|
return c.json({ ok: true, invites: party.invitesFor(name) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
// Bun allows ONE websocket handler per server: party sockets are tagged
|
// Bun allows ONE websocket handler per server: party sockets are tagged
|
||||||
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
||||||
@@ -2291,6 +2420,8 @@ app.get('/api/remote/qr/:code', async (c) => {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
const notes = registerNoteRoutes(app, {
|
const notes = registerNoteRoutes(app, {
|
||||||
db: notesDb,
|
db: notesDb,
|
||||||
|
catalog: videoCatalog,
|
||||||
|
media,
|
||||||
getProfile,
|
getProfile,
|
||||||
profileNameRe: PROFILE_NAME_RE,
|
profileNameRe: PROFILE_NAME_RE,
|
||||||
runYtdlp: runYtdlpResilient,
|
runYtdlp: runYtdlpResilient,
|
||||||
@@ -2304,6 +2435,13 @@ const notes = registerNoteRoutes(app, {
|
|||||||
{ label: 'Database', path: dirname(process.env.DB_PATH || './data/ytplayer.db') },
|
{ label: 'Database', path: dirname(process.env.DB_PATH || './data/ytplayer.db') },
|
||||||
] },
|
] },
|
||||||
});
|
});
|
||||||
|
recoveryAdmin.auth = notes.requireAdmin;
|
||||||
|
const deepSearch = registerDeepSearchRoutes(app, { db: notesDb.db, adminAuth: notes.requireAdminOrToken, autoStart: false,
|
||||||
|
searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads,
|
||||||
|
ingest: collectVideoMetadata, putSearch: searchCacheDb.put });
|
||||||
|
registerAdminToolsRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, media,
|
||||||
|
backupDir: process.env.UPLOAD_BACKUP_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'), backfillStep,
|
||||||
|
pullLyrics: notes.pullLyricsForAdmin, queueWhisper: notes.queueWhisperForAdmin });
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Uploads — the server's own searchable media library (see uploads.js)
|
// Uploads — the server's own searchable media library (see uploads.js)
|
||||||
@@ -2323,6 +2461,7 @@ const uploads = registerUploadRoutes(app, {
|
|||||||
parseLrc,
|
parseLrc,
|
||||||
sanitizeLyrics,
|
sanitizeLyrics,
|
||||||
});
|
});
|
||||||
|
registerRequestMonitorRoutes(app, { adminAuth: notes.requireAdminOrToken, monitor: requestMonitor });
|
||||||
const isUpload = (id) => uploads.isUploadId(id);
|
const isUpload = (id) => uploads.isUploadId(id);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2348,6 +2487,8 @@ const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.ena
|
|||||||
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
|
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
|
||||||
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
|
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
|
||||||
} });
|
} });
|
||||||
|
registerP2pAdminRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, p2pDb, hub: p2pHub,
|
||||||
|
stats: async () => ({ intakeOpen: intake.openTickets(), intakeActive: intake.active(), content: (await p2pDb.p2pStats()).content }) });
|
||||||
|
|
||||||
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
|
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
|
||||||
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
|
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
|
||||||
@@ -2515,6 +2656,7 @@ app.get('/*', indexHtml);
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
async function main() {
|
async function main() {
|
||||||
await initDb();
|
await initDb();
|
||||||
|
deepSearch.start();
|
||||||
startThumbnails();
|
startThumbnails();
|
||||||
await p2pDb.initP2pSchema();
|
await p2pDb.initP2pSchema();
|
||||||
console.log(`[ytplayer] DB ready`);
|
console.log(`[ytplayer] DB ready`);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Explicit Whisper requests produce reviewable drafts, never published notes.
|
// Explicit Whisper requests produce reviewable drafts, never published notes.
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { db as sql } from './db.js';
|
import { db as sql } from './db.js';
|
||||||
|
import { audit } from './audit-log.js';
|
||||||
const ID = /^([\w-]{11}|upl_[a-f0-9]{12})$/;
|
const ID = /^([\w-]{11}|upl_[a-f0-9]{12})$/;
|
||||||
const LEASE_MS = 120000;
|
const LEASE_MS = 120000;
|
||||||
function publicJob(row) {
|
function publicJob(row) {
|
||||||
@@ -16,25 +17,30 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
|
|||||||
if (id.startsWith('upl_')) return db.getUpload(id);
|
if (id.startsWith('upl_')) return db.getUpload(id);
|
||||||
const row = await db.getMedia(id); return row?.status === 'ready' ? row : null;
|
const row = await db.getMedia(id); return row?.status === 'ready' ? row : null;
|
||||||
}
|
}
|
||||||
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
|
async function enqueue(videoId) {
|
||||||
const id = c.req.param('video');
|
const id = String(videoId || '');
|
||||||
if (!ID.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
|
if (!ID.test(id)) return { status: 400, body: { ok: false, error: 'invalid video id' } };
|
||||||
if (!workerEnabled) return c.json({ ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' }, 503);
|
if (!workerEnabled) return { status: 503, body: { ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' } };
|
||||||
const audio = await source(id);
|
const audio = await source(id);
|
||||||
if (!audio) return c.json({ ok: false, error: 'Save this video on the server before transcribing its audio.' }, 409);
|
if (!audio) return { status: 409, body: { ok: false, error: 'Save this video on the server before transcribing its audio.' } };
|
||||||
if (Number(audio.duration) > 3600) return c.json({ ok: false, error: 'Whisper requests are limited to one hour of audio.' }, 422);
|
if (Number(audio.duration) > 3600) return { status: 422, body: { ok: false, error: 'Whisper requests are limited to one hour of audio.' } };
|
||||||
const notes = await db.getNotes(id);
|
const notes = await db.getNotes(id);
|
||||||
// One active job per song and a bounded queue, including across restarts.
|
// One active job per song and a bounded queue, including across restarts.
|
||||||
const existing = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
|
const existing = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
|
||||||
if (existing) return c.json({ ok: true, job: publicJob(existing) });
|
if (existing) return { status: 200, body: { ok: true, job: publicJob(existing) } };
|
||||||
const active = Number((await sql.execute("SELECT COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running')")).rows[0].n);
|
const active = Number((await sql.execute("SELECT COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running')")).rows[0].n);
|
||||||
if (active >= 25) return c.json({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' }, 429);
|
if (active >= 25) return { status: 429, body: { ok: false, error: 'The transcription queue is full. Try again after a job finishes.' } };
|
||||||
const time = now();
|
const time = now();
|
||||||
await sql.execute({ sql: `INSERT OR IGNORE INTO lyric_transcriptions (id,video_id,base_rev,created_at,updated_at)
|
await sql.execute({ sql: `INSERT OR IGNORE INTO lyric_transcriptions (id,video_id,base_rev,created_at,updated_at)
|
||||||
SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM lyric_transcriptions WHERE status IN ('queued','running')) < 25`,
|
SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM lyric_transcriptions WHERE status IN ('queued','running')) < 25`,
|
||||||
args: [randomUUID(), id, notes.lyrics?.rev || 0, time, time] });
|
args: [randomUUID(), id, notes.lyrics?.rev || 0, time, time] });
|
||||||
const row = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
|
const row = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
|
||||||
return row ? c.json({ ok: true, job: publicJob(row) }, 202) : c.json({ ok: false, error: 'The transcription queue is full.' }, 429);
|
if (row) await audit(db, 'admin', 'lyrics.whisper.enqueue', id, { jobId: row.id });
|
||||||
|
return row ? { status: 202, body: { ok: true, job: publicJob(row) } } : { status: 429, body: { ok: false, error: 'The transcription queue is full.' } };
|
||||||
|
}
|
||||||
|
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
|
||||||
|
const result = await enqueue(c.req.param('video'));
|
||||||
|
return c.json(result.body, result.status);
|
||||||
});
|
});
|
||||||
app.get('/api/admin/transcriptions/:video', adminAuth, async c => {
|
app.get('/api/admin/transcriptions/:video', adminAuth, async c => {
|
||||||
const id = c.req.param('video');
|
const id = c.req.param('video');
|
||||||
@@ -76,4 +82,5 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
|
|||||||
await sql.execute({ sql: "DELETE FROM lyric_transcriptions WHERE status IN ('complete','failed') AND updated_at<?", args: [time - 30 * 86400000] });
|
await sql.execute({ sql: "DELETE FROM lyric_transcriptions WHERE status IN ('complete','failed') AND updated_at<?", args: [time - 30 * 86400000] });
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
return { enqueue };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readdirSync, renameSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { audit } from './audit-log.js';
|
||||||
|
|
||||||
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
||||||
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
|
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
|
||||||
@@ -104,6 +105,7 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
const artPath = (id) => locate(`${id}.art.jpg`);
|
const artPath = (id) => locate(`${id}.art.jpg`);
|
||||||
const newArtPath = (id) => join(writeDir(), `${id}.art.jpg`);
|
const newArtPath = (id) => join(writeDir(), `${id}.art.jpg`);
|
||||||
const removeEverywhere = (name) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { try { unlinkSync(join(d, name)); } catch { /* gone */ } } };
|
const removeEverywhere = (name) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { try { unlinkSync(join(d, name)); } catch { /* gone */ } } };
|
||||||
|
const removeStaleCopies = (name, currentPath) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { const p = join(d, name); if (p !== currentPath) try { unlinkSync(p); } catch { /* no alternate copy */ } } };
|
||||||
|
|
||||||
async function probeFile(path) {
|
async function probeFile(path) {
|
||||||
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
|
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
|
||||||
@@ -181,6 +183,7 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
try {
|
try {
|
||||||
await Bun.write(target, file);
|
await Bun.write(target, file);
|
||||||
const r = await ingest({ id, ext, target, size: file.size, filename: file.name, fields: body, artFile: body.art });
|
const r = await ingest({ id, ext, target, size: file.size, filename: file.name, fields: body, artFile: body.art });
|
||||||
|
await audit(db, 'admin', 'upload.create', id, { size: file.size, kind: r.upload.kind });
|
||||||
return c.json({ ok: true, ...r });
|
return c.json({ ok: true, ...r });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup(id, target);
|
cleanup(id, target);
|
||||||
@@ -188,6 +191,85 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/uploads/manage', requireAdminOrToken, async c => {
|
||||||
|
const q = String(c.req.query('q') || '').trim().slice(0, 120), offset = Math.max(0, Number(c.req.query('offset')) || 0);
|
||||||
|
const result = await db.listAdminUploads({ q, offset, limit: 100 });
|
||||||
|
result.quotas = await db.listUploadQuotas?.() || [];
|
||||||
|
const known = new Set(await db.listUploadFileNames());
|
||||||
|
const orphans = [];
|
||||||
|
for (const dir of dirs()) for (const name of readdirSync(dir).slice(0, 10000)) {
|
||||||
|
if (!name.includes('.')) continue;
|
||||||
|
const id = name.split('.')[0];
|
||||||
|
if (UPLOAD_ID_RE.test(id) && !known.has(name) && !name.includes('.replace.')) orphans.push({ name, dir });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, ...result, offset, orphans: orphans.slice(0, 200) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/uploads/quotas/:owner', requireAdminOrToken, async c => {
|
||||||
|
const owner = c.req.param('owner'); if (!/^[\w-]{8,128}$/.test(owner)) return c.json({ ok: false, error: 'invalid owner id' }, 400);
|
||||||
|
const body = await c.req.json().catch(() => ({})), maxBytes = Number(body.maxBytes);
|
||||||
|
if (!Number.isFinite(maxBytes) || maxBytes < 0 || maxBytes > 10 * 1024 ** 4) return c.json({ ok: false, error: 'quota must be zero (unlimited) or a valid byte count' }, 400);
|
||||||
|
await db.setUploadQuota?.(owner, maxBytes || null);
|
||||||
|
await audit(db, 'admin', 'upload.quota.set', owner, { maxBytes: maxBytes || null });
|
||||||
|
return c.json({ ok: true, maxBytes: maxBytes || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/uploads/:id', requireAdminOrToken, async c => {
|
||||||
|
const id = c.req.param('id'); if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||||
|
const body = await c.req.json().catch(() => ({})), before = await db.getUpload(id);
|
||||||
|
if (!before) return c.json({ ok: false, error: 'not found' }, 404);
|
||||||
|
const patch = {};
|
||||||
|
for (const key of ['title','artist','album']) if (body[key] !== undefined) patch[key] = String(body[key]).trim();
|
||||||
|
if (body.listed !== undefined) patch.listed = body.listed === true;
|
||||||
|
await db.updateUploadMeta(id, patch);
|
||||||
|
await audit(db, 'admin', 'upload.update', id, patch);
|
||||||
|
return c.json({ ok: true, upload: await db.getUpload(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/uploads/bulk', requireAdminOrToken, async c => {
|
||||||
|
const body = await c.req.json().catch(() => ({})), ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter(id => UPLOAD_ID_RE.test(String(id))))].slice(0, 200);
|
||||||
|
if (!ids.length || !['delete','list','unlist'].includes(body.action)) return c.json({ ok: false, error: 'choose uploads and delete, list, or unlist' }, 400);
|
||||||
|
let changed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
const u = await db.getUpload(id); if (!u) continue;
|
||||||
|
if (body.action === 'delete') { await db.deleteUpload(id); removeEverywhere(`${u.id}.${u.ext}`); removeEverywhere(`${id}.art.jpg`); }
|
||||||
|
else await db.updateUploadMeta(id, { listed: body.action === 'list' });
|
||||||
|
await audit(db, 'admin', `upload.bulk.${body.action}`, id); changed++;
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, changed });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/uploads/:id/replace', requireAdminOrToken, async c => {
|
||||||
|
const id = c.req.param('id'), old = UPLOAD_ID_RE.test(id) ? await db.getUpload(id) : null;
|
||||||
|
if (!old) return c.json({ ok: false, error: 'not found' }, 404);
|
||||||
|
const filename = String(c.req.query('name') || 'replacement.' + old.ext), ext = extOf(filename);
|
||||||
|
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
|
||||||
|
const declared = Number(c.req.header('content-length')); if (declared > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
|
||||||
|
const reader = c.req.raw.body?.getReader(); if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
|
||||||
|
const dir = writeDir(), temp = join(dir, `${id}.replace.${randomBytes(4).toString('hex')}.${ext}`), target = join(dir, `${id}.${ext}`);
|
||||||
|
let got = 0; const out = createWriteStream(temp);
|
||||||
|
try {
|
||||||
|
try { for (;;) { const { done, value } = await reader.read(); if (done) break; got += value.byteLength; if (got > MAX_BYTES) throw Object.assign(Error('file is larger than 4 GB'), { status: 413 }); if (!out.write(value)) await new Promise(r => out.once('drain', r)); } }
|
||||||
|
finally { await new Promise(r => out.end(r)); }
|
||||||
|
if (!got || (declared > 0 && got !== declared)) throw Object.assign(Error('empty or incomplete replacement'), { status: 400 });
|
||||||
|
const probe = await probeFile(temp), info = describeProbe(probe, filename);
|
||||||
|
if (!info.hasAudio) throw Object.assign(Error('replacement has no audio stream'), { status: 415 });
|
||||||
|
const backup = `${target}.previous`;
|
||||||
|
const existingTarget = existsSync(target);
|
||||||
|
if (existingTarget) renameSync(target, backup);
|
||||||
|
try {
|
||||||
|
renameSync(temp, target);
|
||||||
|
await db.replaceUploadFile(id, { kind: info.kind, title: String(c.req.query('title') ?? old.title).slice(0,300), artist: String(c.req.query('artist') ?? old.artist).slice(0,200), album: String(c.req.query('album') ?? old.album).slice(0,200), duration: info.duration, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'), size: got, art: old.art });
|
||||||
|
} catch (error) { try { unlinkSync(target); } catch {} if (existingTarget) renameSync(backup, target); throw error; }
|
||||||
|
if (existingTarget) try { unlinkSync(backup); } catch {}
|
||||||
|
removeStaleCopies(`${id}.${ext}`, target);
|
||||||
|
if (old.ext !== ext) removeEverywhere(`${id}.${old.ext}`);
|
||||||
|
await audit(db, 'admin', 'upload.replace', id, { oldExt: old.ext, ext, size: got });
|
||||||
|
return c.json({ ok: true, upload: await db.getUpload(id) });
|
||||||
|
} catch (error) { return c.json({ ok: false, error: error.status ? error.message : 'replacement failed validation' }, error.status || 422); }
|
||||||
|
finally { try { unlinkSync(temp); } catch {} }
|
||||||
|
});
|
||||||
|
|
||||||
// Streaming upload: the raw file is the request body, copied to disk chunk by
|
// Streaming upload: the raw file is the request body, copied to disk chunk by
|
||||||
// chunk so a multi-GB (hour-long) video never sits in memory.
|
// chunk so a multi-GB (hour-long) video never sits in memory.
|
||||||
// PUT /api/admin/uploads/stream?name=talk.mp4&title=&artist=&album=
|
// PUT /api/admin/uploads/stream?name=talk.mp4&title=&artist=&album=
|
||||||
@@ -219,6 +301,7 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
|
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
|
||||||
const q = (k) => String(c.req.query(k) || '');
|
const q = (k) => String(c.req.query(k) || '');
|
||||||
const r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: q('title'), artist: q('artist'), album: q('album') } });
|
const r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: q('title'), artist: q('artist'), album: q('album') } });
|
||||||
|
await audit(db, 'admin', 'upload.create', id, { size: got, kind: r.upload.kind });
|
||||||
return c.json({ ok: true, ...r });
|
return c.json({ ok: true, ...r });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup(id, target);
|
cleanup(id, target);
|
||||||
@@ -252,6 +335,8 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
|
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
|
||||||
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
|
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
|
||||||
const use = await db.sharedUploadUsage(fp);
|
const use = await db.sharedUploadUsage(fp);
|
||||||
|
const ownerQuota = await db.getUploadQuota?.(fp);
|
||||||
|
if (ownerQuota && use.bytes + declared > ownerQuota) return c.json({ ok: false, error: `owner storage quota of ${mb(ownerQuota)} reached` }, 413);
|
||||||
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
|
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
|
||||||
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
|
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
|
||||||
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
|
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
|
||||||
@@ -268,8 +353,8 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
got += value.byteLength;
|
got += value.byteLength;
|
||||||
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) {
|
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes || (ownerQuota && use.bytes + got > ownerQuota)) {
|
||||||
throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { status: 413 });
|
throw Object.assign(new Error(`shared storage quota reached (owner cap ${ownerQuota ? mb(ownerQuota) : mb(SHARED.deviceBytes)})`), { status: 413 });
|
||||||
}
|
}
|
||||||
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
|
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
|
||||||
}
|
}
|
||||||
@@ -306,6 +391,8 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
await Bun.write(tmp, buf);
|
await Bun.write(tmp, buf);
|
||||||
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", newArtPath(id)]);
|
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", newArtPath(id)]);
|
||||||
await db.setUploadArt(id, 'file');
|
await db.setUploadArt(id, 'file');
|
||||||
|
removeStaleCopies(`${id}.art.jpg`, newArtPath(id));
|
||||||
|
await audit(db, 'admin', 'upload.art.replace', id, { size: buf.byteLength });
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: 'unusable image: ' + err.message }, 400);
|
return c.json({ ok: false, error: 'unusable image: ' + err.message }, 400);
|
||||||
@@ -320,6 +407,7 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
const u = await db.getUpload(id);
|
const u = await db.getUpload(id);
|
||||||
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
|
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
|
||||||
await db.deleteUpload(id);
|
await db.deleteUpload(id);
|
||||||
|
await audit(db, 'admin', 'upload.delete', id, { size: u.size });
|
||||||
removeEverywhere(`${u.id}.${u.ext}`);
|
removeEverywhere(`${u.id}.${u.ext}`);
|
||||||
removeEverywhere(`${id}.art.jpg`);
|
removeEverywhere(`${id}.art.jpg`);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -335,6 +423,8 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||||
const u = await db.getUpload(id);
|
const u = await db.getUpload(id);
|
||||||
|
const requestedGen = Number(c.req.query('g'));
|
||||||
|
if (requestedGen && requestedGen !== Number(u?.gen || 1)) return c.json({ ok: false, error: 'upload generation changed' }, 409, { 'Cache-Control': 'no-store' });
|
||||||
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
|
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
|
||||||
db.touchUpload(id);
|
db.touchUpload(id);
|
||||||
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
|
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
|
||||||
@@ -343,6 +433,8 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
app.get('/api/uploads/:id/art', async (c) => {
|
app.get('/api/uploads/:id/art', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||||
|
const row = await db.getUpload(id), requestedGen = Number(c.req.query('g'));
|
||||||
|
if (requestedGen && requestedGen !== Number(row?.artGen || 1)) return c.json({ ok: false, error: 'upload artwork generation changed' }, 409, { 'Cache-Control': 'no-store' });
|
||||||
const p = artPath(id);
|
const p = artPath(id);
|
||||||
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
|
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
|
||||||
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
|
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
|
||||||
@@ -351,20 +443,20 @@ export function registerUploadRoutes(app, deps) {
|
|||||||
// /api/streams payload for an upload: one "Original" quality for video,
|
// /api/streams payload for an upload: one "Original" quality for video,
|
||||||
// audio + cover art for audio (the app shows the art where the video goes).
|
// audio + cover art for audio (the app shows the art where the video goes).
|
||||||
function streamsPayload(u) {
|
function streamsPayload(u) {
|
||||||
const art = u.art ? `/api/uploads/${u.id}/art` : '';
|
const art = u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '';
|
||||||
const url = `/api/uploads/${u.id}`;
|
const url = `/api/uploads/${u.id}?g=${u.gen || 1}`;
|
||||||
return {
|
return {
|
||||||
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
|
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
|
||||||
audioUrl: u.kind === 'audio' ? url : '',
|
audioUrl: u.kind === 'audio' ? url : '',
|
||||||
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
|
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
|
||||||
upload: true,
|
upload: true, gen: u.gen || 1,
|
||||||
kind: u.kind,
|
kind: u.kind,
|
||||||
art,
|
art,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const card = (u) => ({
|
const card = (u) => ({
|
||||||
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '',
|
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', gen: u.gen || 1,
|
||||||
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind,
|
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '', upload: true, kind: u.kind,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };
|
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };
|
||||||
|
|||||||