From 18e4459bab99acc914417b701d160b8978200e47 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Wed, 14 Oct 2026 01:17:30 +0800 Subject: [PATCH] Add Axum backend experiment --- alt/bench.mjs | 39 + alt/compare.mjs | 71 ++ alt/rust/.gitignore | 1 + alt/rust/BENCH.md | 27 + alt/rust/Cargo.lock | 1919 +++++++++++++++++++++++++++++++++++ alt/rust/Cargo.toml | 25 + alt/rust/Dockerfile | 22 + alt/rust/README.md | 29 + alt/rust/browser-smoke.mjs | 26 + alt/rust/docker-compose.yml | 32 + alt/rust/src/main.rs | 1599 +++++++++++++++++++++++++++++ alt/rust/src/manifest.rs | 464 +++++++++ 12 files changed, 4254 insertions(+) create mode 100644 alt/bench.mjs create mode 100644 alt/compare.mjs create mode 100644 alt/rust/.gitignore create mode 100644 alt/rust/BENCH.md create mode 100644 alt/rust/Cargo.lock create mode 100644 alt/rust/Cargo.toml create mode 100644 alt/rust/Dockerfile create mode 100644 alt/rust/README.md create mode 100644 alt/rust/browser-smoke.mjs create mode 100644 alt/rust/docker-compose.yml create mode 100644 alt/rust/src/main.rs create mode 100644 alt/rust/src/manifest.rs diff --git a/alt/bench.mjs b/alt/bench.mjs new file mode 100644 index 0000000..7ff0fc2 --- /dev/null +++ b/alt/bench.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +const base = process.argv[2]; +if (!base) { console.error('usage: node alt/bench.mjs [--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))); +} diff --git a/alt/compare.mjs b/alt/compare.mjs new file mode 100644 index 0000000..5ff339a --- /dev/null +++ b/alt/compare.mjs @@ -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 [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(`]*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; diff --git a/alt/rust/.gitignore b/alt/rust/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/alt/rust/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/alt/rust/BENCH.md b/alt/rust/BENCH.md new file mode 100644 index 0000000..b327840 --- /dev/null +++ b/alt/rust/BENCH.md @@ -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. diff --git a/alt/rust/Cargo.lock b/alt/rust/Cargo.lock new file mode 100644 index 0000000..5dc4ecd --- /dev/null +++ b/alt/rust/Cargo.lock @@ -0,0 +1,1919 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "ytplayer-rust-backend" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "brotli", + "bytes", + "flate2", + "futures-util", + "hex", + "http", + "mime_guess", + "regex", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-util", + "tower-http", + "url", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.1+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/alt/rust/Cargo.toml b/alt/rust/Cargo.toml new file mode 100644 index 0000000..edcf5a3 --- /dev/null +++ b/alt/rust/Cargo.toml @@ -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" diff --git a/alt/rust/Dockerfile b/alt/rust/Dockerfile new file mode 100644 index 0000000..f49545d --- /dev/null +++ b/alt/rust/Dockerfile @@ -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"] diff --git a/alt/rust/README.md b/alt/rust/README.md new file mode 100644 index 0000000..a68c8f0 --- /dev/null +++ b/alt/rust/README.md @@ -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 [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 ` 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. diff --git a/alt/rust/browser-smoke.mjs b/alt/rust/browser-smoke.mjs new file mode 100644 index 0000000..e800a96 --- /dev/null +++ b/alt/rust/browser-smoke.mjs @@ -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(); +} diff --git a/alt/rust/docker-compose.yml b/alt/rust/docker-compose.yml new file mode 100644 index 0000000..578a0f6 --- /dev/null +++ b/alt/rust/docker-compose.yml @@ -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 diff --git a/alt/rust/src/main.rs b/alt/rust/src/main.rs new file mode 100644 index 0000000..554f147 --- /dev/null +++ b/alt/rust/src/main.rs @@ -0,0 +1,1599 @@ +mod manifest; + +use axum::{ + body::Body, + extract::{Path as AxumPath, Query, State}, + http::{header, HeaderMap, Method, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use brotli::CompressorWriter; +use flate2::{write::GzEncoder, Compression}; +use futures_util::StreamExt; +use manifest::{load as load_assets, AssetSnapshot}; +use reqwest::Client; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::{ + collections::HashMap, + env, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncSeekExt, SeekFrom}, + process::Command, + sync::RwLock, +}; +use tokio_util::io::ReaderStream; +use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer}; + +#[derive(Clone)] +struct AppState { + assets: Arc, + compressed: Arc>, + db: Arc>, + client: Client, + media_dir: PathBuf, + marker: Option, + max_cache_bytes: u64, + yt_dlp: String, + ffmpeg: String, + ffprobe: String, + search_mem: Arc>>, + stream_mem: Arc>>, + jobs: Arc>>, + app_version: String, + hashing: bool, +} +#[derive(Clone)] +struct EncodedAsset { + br: bytes::Bytes, + gzip: bytes::Bytes, +} +#[derive(Clone)] +struct SearchEntry { + cards: Vec, + at: i64, +} +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} +fn now_s() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} +fn q(query: &HashMap, key: &str) -> String { + query.get(key).cloned().unwrap_or_default() +} +fn api_error(status: StatusCode, error: impl Into) -> Response { + (status, Json(json!({"ok":false,"error":error.into()}))).into_response() +} +fn conn(db: &Arc>) -> std::sync::MutexGuard<'_, Connection> { + db.lock().unwrap_or_else(|p| p.into_inner()) +} +fn compressible(path: &str) -> bool { + matches!( + Path::new(path) + .extension() + .and_then(|x| x.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("css" | "js" | "html" | "json" | "webmanifest" | "svg" | "txt") + ) +} +fn encode_asset(content: &[u8]) -> Result { + let mut br = Vec::new(); + { + let mut writer = CompressorWriter::new(&mut br, 4096, 5, 22); + writer.write_all(content)?; + } + let mut gzip = Vec::new(); + { + let mut writer = GzEncoder::new(&mut gzip, Compression::default()); + writer.write_all(content)?; + writer.finish()?; + } + Ok(EncodedAsset { + br: bytes::Bytes::from(br), + gzip: bytes::Bytes::from(gzip), + }) +} +fn asset_content_type(path: &str) -> String { + match Path::new(path) + .extension() + .and_then(|x| x.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("js") => "text/javascript; charset=utf-8".into(), + Some("css") => "text/css; charset=utf-8".into(), + Some("html") => "text/html; charset=utf-8".into(), + Some("json") => "application/json".into(), + Some("webmanifest") => "application/manifest+json".into(), + Some("svg") => "image/svg+xml".into(), + Some("txt") => "text/plain; charset=utf-8".into(), + _ => mime_guess::from_path(path.trim_start_matches('/')) + .first_or_octet_stream() + .as_ref() + .to_string(), + } +} +fn negotiated_encoding( + headers: &HeaderMap, + compressed: &HashMap, + path: &str, +) -> Option<(&'static str, bytes::Bytes)> { + let accepted = headers + .get(header::ACCEPT_ENCODING)? + .to_str() + .ok()? + .to_ascii_lowercase(); + let encoded = compressed.get(path)?; + let accepts = |name: &str| { + accepted.split(',').any(|item| { + let mut fields = item.trim().split(';'); + if fields.next().unwrap_or("").trim() != name { + return false; + } + fields.all(|field| { + let Some((key, value)) = field.trim().split_once('=') else { + return true; + }; + key.trim() != "q" || value.trim().parse::().unwrap_or(1.0) > 0.0 + }) + }) + }; + if accepts("br") { + Some(("br", encoded.br.clone())) + } else if accepts("gzip") { + Some(("gzip", encoded.gzip.clone())) + } else { + None + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let port = env::var("PORT").unwrap_or_else(|_| "3000".into()); + let root = PathBuf::from(env::var("PUBLIC_DIR").unwrap_or_else(|_| "./public".into())); + let fallback_root = if root.exists() { + root.clone() + } else { + PathBuf::from("./frontend") + }; + let root = if fallback_root.exists() { + fallback_root + } else { + root + }; + let hashing = env::var("ASSET_HASHING").map(|v| v != "0").unwrap_or(true); + let assets = Arc::new(load_assets( + &root, + hashing, + env::var("ASSET_SYNC").map(|v| v != "0").unwrap_or(true), + env::var("BUILD_TAG").ok().as_deref(), + )?); + let compressed = Arc::new( + assets + .bytes + .iter() + .filter(|(path, _)| compressible(path)) + .map(|(path, content)| Ok((path.clone(), encode_asset(content)?))) + .collect::, std::io::Error>>()?, + ); + let db_path = + PathBuf::from(env::var("DB_PATH").unwrap_or_else(|_| "./data/ytplayer.db".into())); + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent).await?; + } + let media_dir = PathBuf::from(env::var("MEDIA_DIR").unwrap_or_else(|_| "./data/media".into())); + fs::create_dir_all(&media_dir).await?; + let db_path_for_open = db_path.clone(); + let database = + tokio::task::spawn_blocking(move || Connection::open(db_path_for_open)).await??; + database.pragma_update(None, "journal_mode", "WAL")?; + database.pragma_update(None, "synchronous", "NORMAL")?; + database.execute_batch(SCHEMA)?; + ensure_media_sha256_column(&database)?; + let state = AppState { + assets, + compressed, + db: Arc::new(Mutex::new(database)), + client: Client::builder().user_agent("ytplayer-rust/0.1").build()?, + media_dir, + marker: env::var("MEDIA_VOLUME_MARKER") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from), + max_cache_bytes: env::var("MEDIA_CACHE_MAX_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10 * 1024 * 1024 * 1024), + yt_dlp: env::var("YTDLP_PATH").unwrap_or_else(|_| "yt-dlp".into()), + ffmpeg: env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".into()), + ffprobe: env::var("FFPROBE_PATH").unwrap_or_else(|_| "ffprobe".into()), + search_mem: Arc::new(RwLock::new(HashMap::new())), + stream_mem: Arc::new(RwLock::new(HashMap::new())), + jobs: Arc::new(Mutex::new(HashMap::new())), + app_version: env::var("APP_VERSION").unwrap_or_else(|_| "1.0.0".into()), + hashing, + }; + let build_tag = state.assets.build_tag.clone(); + let app = Router::new() + .route("/api/version", get(version)) + .route("/api/manifest", get(asset_manifest)) + .route("/api/search", get(search)) + .route("/api/search/local", get(search_local)) + .route("/api/streams", get(streams)) + .route("/api/streams/warm", get(streams_warm)) + .route("/api/play", get(play)) + .route("/api/media/{id}", get(media_file).head(media_file)) + .route("/api/media/{id}/status", get(media_status)) + .route("/api/download/{id}/prepare", get(download_prepare)) + .route("/api/download/{id}", get(download_file)) + .route("/api/user/data", get(user_data)) + .route("/api/user/sync", post(user_sync)) + .route("/api/profile/info", get(profile_info)) + .route("/api/profile/load", get(profile_load)) + .route("/api/profile/create", post(profile_create)) + .route("/api/profile/save", post(profile_save)) + .route("/sw.js", get(service_worker)) + .fallback(static_fallback) + .with_state(state) + .layer(CompressionLayer::new().gzip(true).br(true)) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; + println!( + "ytplayer Rust backend listening on {port}, build {}", + build_tag + ); + axum::serve(listener, app).await?; + Ok(()) +} + +const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS users(fingerprint TEXT PRIMARY KEY,last_version TEXT,last_seen INTEGER NOT NULL DEFAULT (unixepoch())); +CREATE TABLE IF NOT EXISTS playlists(fingerprint TEXT PRIMARY KEY,data TEXT NOT NULL DEFAULT '[]',updated_at INTEGER NOT NULL DEFAULT (unixepoch())); +CREATE TABLE IF NOT EXISTS video_history(fingerprint TEXT NOT NULL,video_id TEXT NOT NULL,title TEXT,channel TEXT,thumbnail TEXT,duration REAL DEFAULT 0,accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),PRIMARY KEY(fingerprint,video_id)); +CREATE INDEX IF NOT EXISTS idx_vh_fp_time ON video_history(fingerprint,accessed_at DESC); +CREATE TABLE IF NOT EXISTS profiles(name TEXT PRIMARY KEY,data TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL DEFAULT (unixepoch()),updated_at INTEGER NOT NULL DEFAULT (unixepoch())); +CREATE TABLE IF NOT EXISTS media_cache(video_id TEXT PRIMARY KEY,status TEXT NOT NULL DEFAULT 'queued',gen INTEGER NOT NULL DEFAULT 0,size INTEGER NOT NULL DEFAULT 0,height INTEGER NOT NULL DEFAULT 0,vcodec TEXT,acodec TEXT,duration REAL NOT NULL DEFAULT 0,optimized INTEGER NOT NULL DEFAULT 0,meta TEXT NOT NULL DEFAULT '{}',priority INTEGER NOT NULL DEFAULT 1,auto INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,error TEXT,retry_at INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL DEFAULT 0,last_access INTEGER NOT NULL DEFAULT 0,hits INTEGER NOT NULL DEFAULT 0,sha256 TEXT); +CREATE TABLE IF NOT EXISTS search_cache(q TEXT PRIMARY KEY,results TEXT NOT NULL,size INTEGER NOT NULL DEFAULT 0,fetched_at INTEGER NOT NULL,last_access INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS video_meta(id TEXT PRIMARY KEY,card TEXT NOT NULL,hay TEXT NOT NULL DEFAULT '',seen INTEGER NOT NULL DEFAULT 1,updated_at INTEGER NOT NULL); +"#; + +// Existing Bun installations use the same media_cache table without the +// optional digest column. Upgrade that table in place before Rust handlers +// select or update sha256; CREATE TABLE IF NOT EXISTS cannot add columns. +fn ensure_media_sha256_column(database: &Connection) -> rusqlite::Result<()> { + let has_sha256 = { + let mut statement = database.prepare("PRAGMA table_info(media_cache)")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + columns.iter().any(|column| column == "sha256") + }; + if !has_sha256 { + database.execute("ALTER TABLE media_cache ADD COLUMN sha256 TEXT", [])?; + } + Ok(()) +} + +async fn version(State(s): State) -> Response { + ( + [(header::CACHE_CONTROL, "no-store, no-cache, must-revalidate")], + Json( + json!({"version":s.app_version,"buildTag":s.assets.build_tag,"buildTime":Value::Null}), + ), + ) + .into_response() +} +async fn asset_manifest(State(s): State, headers: HeaderMap) -> Response { + let etag = format!("\"{}\"", s.assets.build_tag); + if headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|v| v.split(',').any(|x| x.trim() == etag || x.trim() == "*")) + .unwrap_or(false) + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::CACHE_CONTROL, "no-store") + .header(header::ETAG, etag) + .body(Body::empty()) + .unwrap(); + } + Response::builder() + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .header(header::ETAG, etag) + .body(Body::from(s.assets.manifest.to_string())) + .unwrap() +} +async fn service_worker(State(s): State, headers: HeaderMap) -> Response { + bytes_response( + &s, + "/sw.js", + Some("no-store, no-cache, must-revalidate"), + &headers, + ) +} + +async fn static_fallback( + State(s): State, + method: Method, + uri: axum::http::Uri, + headers: HeaderMap, +) -> Response { + if uri.path().starts_with("/api/") { + return api_error(StatusCode::NOT_IMPLEMENTED, "not in this build"); + } + if method != Method::GET && method != Method::HEAD { + return api_error(StatusCode::NOT_FOUND, "not found"); + } + let raw = uri.path(); + let path = if raw == "/" { "/index.html" } else { raw }; + let path_obj = Path::new(path.trim_start_matches('/')); + if path_obj + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))) + { + return api_error(StatusCode::BAD_REQUEST, "invalid path"); + } + let version = uri.query().and_then(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == "v") + .map(|(_, v)| v.into_owned()) + }); + if s.assets.bytes.contains_key(path) { + return file_response( + &s, + path, + version.as_deref(), + &headers, + method == Method::HEAD, + ); + } + if !path.starts_with("/api/") && path != "/sw.js" { + return file_response(&s, "/index.html", None, &headers, method == Method::HEAD); + } + api_error(StatusCode::NOT_FOUND, "not found") +} +fn bytes_response(s: &AppState, path: &str, cc: Option<&str>, headers: &HeaderMap) -> Response { + let Some(data) = s.assets.bytes.get(path) else { + return api_error(StatusCode::NOT_FOUND, "not found"); + }; + let selected = negotiated_encoding(headers, &s.compressed, path); + let body = selected + .as_ref() + .map(|(_, body)| body.clone()) + .unwrap_or_else(|| data.clone()); + let mut response = Response::builder() + .header(header::CONTENT_TYPE, asset_content_type(path)) + .header(header::CACHE_CONTROL, cc.unwrap_or("no-cache")); + if let Some(etag) = s.assets.etags.get(path) { + response = response.header(header::ETAG, etag.as_str()); + } + if let Some(file) = s.assets.delivery_files.get(path) { + response = response.header("X-Asset-Hash", file.h.as_str()); + } + if compressible(path) { + response = response.header(header::VARY, "Accept-Encoding"); + } + if let Some((encoding, _)) = selected { + response = response + .header(header::CONTENT_ENCODING, encoding) + .header(header::CONTENT_LENGTH, body.len().to_string()); + } else { + response = response.header(header::CONTENT_LENGTH, body.len().to_string()); + } + response.body(Body::from(body)).unwrap() +} +fn file_response( + s: &AppState, + path: &str, + version: Option<&str>, + headers: &HeaderMap, + head: bool, +) -> Response { + let Some(data) = s.assets.bytes.get(path) else { + return api_error(StatusCode::NOT_FOUND, "not found"); + }; + let file = s.assets.delivery_files.get(path); + let cc = manifest::cache_control(path, version, file, s.hashing, &s.assets.build_tag); + let etag = s.assets.etags.get(path).map(String::as_str).unwrap_or(""); + if !etag.is_empty() + && headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|value| { + value.split(',').any(|part| { + part.trim().strip_prefix("W/").unwrap_or(part.trim()) == etag + || part.trim() == "*" + }) + }) + .unwrap_or(false) + { + let mut not_modified = Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::CACHE_CONTROL, cc) + .header(header::ETAG, etag); + if compressible(path) { + not_modified = not_modified.header(header::VARY, "Accept-Encoding"); + } + if let Some(f) = file { + not_modified = not_modified.header("X-Asset-Hash", f.h.as_str()); + } + return not_modified.body(Body::empty()).unwrap(); + } + let selected = negotiated_encoding(headers, &s.compressed, path); + let body = selected + .as_ref() + .map(|(_, body)| body.clone()) + .unwrap_or_else(|| data.clone()); + let mut b = Response::builder() + .header(header::CONTENT_TYPE, asset_content_type(path)) + .header(header::CACHE_CONTROL, cc) + .header(header::CONTENT_LENGTH, body.len().to_string()); + if compressible(path) { + b = b.header(header::VARY, "Accept-Encoding"); + } + if let Some((encoding, _)) = selected { + b = b.header(header::CONTENT_ENCODING, encoding); + } + if !etag.is_empty() { + b = b.header(header::ETAG, etag); + } + if let Some(f) = file { + b = b.header("X-Asset-Hash", f.h.as_str()); + } + b.body(if head { + Body::empty() + } else { + Body::from(body) + }) + .unwrap() +} + +#[derive(Deserialize)] +struct SearchQuery { + q: Option, + refresh: Option, +} +async fn search(State(s): State, Query(qp): Query) -> Response { + let query = qp.q.unwrap_or_default().trim().to_string(); + if query.is_empty() { + return api_error(StatusCode::BAD_REQUEST, "empty query"); + } + let key = query.to_lowercase(); + if qp.refresh.as_deref() != Some("1") { + if let Some(e) = s.search_mem.read().await.get(&key).cloned() { + let age = now() - e.at; + if age < 600_000 { + return Json(json!({"ok":true,"results":e.cards,"fetchedAt":e.at})).into_response(); + } + if age < 15 * 86_400_000 { + let ss = s.clone(); + let qq = query.clone(); + tokio::spawn(async move { + let _ = fetch_search(&ss, &qq).await; + }); + return Json(json!({"ok":true,"results":e.cards,"stale":true,"fetchedAt":e.at})) + .into_response(); + } + } + if let Ok(Some((cards, at))) = load_search_db(&s, &key).await { + let age = now() - at; + if age < 600_000 { + s.search_mem.write().await.insert( + key.clone(), + SearchEntry { + cards: cards.clone(), + at, + }, + ); + return Json(json!({"ok":true,"results":cards,"fetchedAt":at})).into_response(); + } + if age < 15 * 86_400_000 { + s.search_mem.write().await.insert( + key.clone(), + SearchEntry { + cards: cards.clone(), + at, + }, + ); + let ss = s.clone(); + let qq = query.clone(); + tokio::spawn(async move { + let _ = fetch_search(&ss, &qq).await; + }); + return Json(json!({"ok":true,"results":cards,"stale":true,"fetchedAt":at})) + .into_response(); + } + } + } + match fetch_search(&s, &query).await { + Ok((cards, at)) => Json(json!({"ok":true,"results":cards,"fetchedAt":at})).into_response(), + Err(e) => api_error(StatusCode::BAD_GATEWAY, e), + } +} +async fn search_local(State(s): State, Query(qp): Query) -> Response { + let query = qp.q.unwrap_or_default().trim().to_lowercase(); + if query.is_empty() { + return api_error(StatusCode::BAD_REQUEST, "empty query"); + } + let words = query + .split_whitespace() + .take(8) + .map(str::to_string) + .collect::>(); + let db = s.db.clone(); + let known = tokio::task::spawn_blocking(move || { + let c = conn(&db); + let mut stmt = + c.prepare("SELECT card FROM video_meta ORDER BY seen DESC,updated_at DESC LIMIT 1000")?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows.flatten() { + if let Ok(v) = serde_json::from_str::(&row) { + let hay = format!( + "{} {}", + v["title"].as_str().unwrap_or("").to_lowercase(), + v["channel"].as_str().unwrap_or("").to_lowercase() + ); + if words.iter().all(|w| hay.contains(w)) { + out.push(v); + if out.len() >= 60 { + break; + } + } + } + } + Ok::<_, rusqlite::Error>(out) + }) + .await + .unwrap_or(Ok(Vec::new())) + .unwrap_or_default(); + Json(json!({"ok":true,"results":known,"local":true})).into_response() +} +async fn fetch_search(s: &AppState, q: &str) -> Result<(Vec, i64), String> { + let mut cards = innertube_search(s, q).await.unwrap_or_default(); + if cards.is_empty() { + let output = Command::new(&s.yt_dlp) + .args([ + format!("ytsearch25:{q}"), + "--dump-json".into(), + "--flat-playlist".into(), + "--no-warnings".into(), + "--ignore-errors".into(), + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Ok(j) = serde_json::from_str::(line) { + if let Some(c) = card_from_ytdlp(&j) { + cards.push(c) + } + } + } + } + cards.truncate(200); + let at = now(); + let key = q.to_lowercase(); + let cloned = cards.clone(); + let db = s.db.clone(); + let _ = tokio::task::spawn_blocking(move || { + let c = conn(&db); + let text = serde_json::to_string(&cloned).unwrap_or_else(|_| "[]".into()); + c.execute("INSERT INTO search_cache(q,results,size,fetched_at,last_access) VALUES(?1,?2,?3,?4,?4) ON CONFLICT(q) DO UPDATE SET results=excluded.results,size=excluded.size,fetched_at=excluded.fetched_at,last_access=excluded.last_access", params![key, text, text.len(), at])?; + for card in cloned { + let id = card["id"].as_str().unwrap_or(""); + let hay = format!("{} {}", card["title"].as_str().unwrap_or(""), card["channel"].as_str().unwrap_or("")).to_lowercase(); + c.execute("INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?1,?2,?3,1,?4) ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=video_meta.seen+1,updated_at=excluded.updated_at", params![id, card.to_string(), hay, at])?; + } + Ok::<_, rusqlite::Error>(()) + }).await; + s.search_mem.write().await.insert( + q.to_lowercase(), + SearchEntry { + cards: cards.clone(), + at, + }, + ); + Ok((cards, at)) +} +async fn load_search_db(s: &AppState, key: &str) -> rusqlite::Result, i64)>> { + let db = s.db.clone(); + let k = key.to_string(); + tokio::task::spawn_blocking(move || { + let c = conn(&db); + let row = c + .query_row( + "SELECT results,fetched_at FROM search_cache WHERE q=?1", + [k], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)), + ) + .optional()?; + Ok(row.and_then(|(v, t)| serde_json::from_str(&v).ok().map(|x| (x, t)))) + }) + .await + .unwrap_or(Err(rusqlite::Error::InvalidQuery)) +} +fn card_from_ytdlp(j: &Value) -> Option { + let id = j["id"].as_str()?; + Some( + json!({"id":id,"title":j["title"].as_str().unwrap_or("(untitled)"),"channel":j["channel"].as_str().or(j["uploader"].as_str()).unwrap_or(""),"channelId":j["channel_id"].as_str().or(j["uploader_id"].as_str()).unwrap_or(""),"channelUrl":j["channel_url"].as_str().or(j["uploader_url"].as_str()).unwrap_or(""),"duration":j["duration"].as_f64().unwrap_or(0.0),"thumbnail":format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")}), + ) +} +async fn innertube_search(s: &AppState, q: &str) -> Result, String> { + let body = json!({"context":{"client":{"clientName":"WEB","clientVersion":"2.20250101.00.00","hl":"en","gl":"US"}},"query":q}); + let response = s + .client + .post("https://www.youtube.com/youtubei/v1/search?prettyPrint=false") + .header("Content-Type", "application/json") + .json(&body) + .timeout(Duration::from_secs(7)) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("InnerTube HTTP {}", response.status())); + } + let value: Value = response.json().await.map_err(|e| e.to_string())?; + let mut out = Vec::new(); + walk_video_renderers(&value, &mut out); + Ok(out) +} +fn walk_video_renderers(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + if let Some(r) = m.get("videoRenderer") { + if let Some(id) = r["videoId"].as_str() { + let title = r["title"]["runs"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x["text"].as_str()) + .collect::() + }) + .unwrap_or_else(|| "(untitled)".into()); + let owner = &r["ownerText"]["runs"][0]; + let cid = owner["navigationEndpoint"]["browseEndpoint"]["browseId"] + .as_str() + .unwrap_or(""); + let url = owner["navigationEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] + .as_str() + .unwrap_or(""); + let dur = r["lengthText"]["simpleText"] + .as_str() + .map(parse_duration) + .unwrap_or(0); + out.push(json!({"id":id,"title":title,"channel":owner["text"].as_str().unwrap_or(""),"channelId":cid,"channelUrl":if url.is_empty(){String::new()}else{format!("https://www.youtube.com{url}")},"duration":dur,"thumbnail":format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")})); + } + } + for x in m.values() { + walk_video_renderers(x, out) + } + } + Value::Array(a) => { + for x in a { + walk_video_renderers(x, out) + } + } + _ => {} + } +} +fn parse_duration(s: &str) -> u64 { + s.split(':') + .filter_map(|p| p.parse::().ok()) + .fold(0, |a, n| a * 60 + n) +} + +#[derive(Deserialize)] +struct IdQuery { + v: Option, + nocache: Option, + hevc: Option, +} +async fn streams(State(s): State, Query(qp): Query) -> Response { + let Some(id) = qp.v.filter(|x| valid_id(x)) else { + return api_error(StatusCode::BAD_REQUEST, "missing videoId"); + }; + if qp.nocache.as_deref() != Some("1") { + if let Some(row) = media_row(&s, &id).await { + if row.status == "ready" + && (row.vcodec.as_deref() != Some("hevc") || qp.hevc.as_deref() == Some("1")) + { + let url = format!("/api/media/{}?g={}", id, row.gen); + let payload = json!({"meta":{"id":id,"title":row.meta["title"].as_str().unwrap_or("(untitled)"),"channel":row.meta["channel"].as_str().unwrap_or(""),"channelId":row.meta["channelId"].as_str().unwrap_or(""),"channelUrl":row.meta["channelUrl"].as_str().unwrap_or(""),"duration":row.meta["duration"].as_u64().unwrap_or(row.duration as u64),"thumbnail":format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg")},"audioUrl":format!("{url}&a=1"),"qualities":[{"label":format!("{}p",if row.height>0{row.height}else{720}),"height":if row.height>0{row.height}else{720},"hasAudio":true,"url":url,"ext":"mp4","codec":row.vcodec.as_deref().unwrap_or("h264")}],"serverCached":true,"cid":row.sha256}); + return Json(json!({"ok":true,"data":payload})).into_response(); + } + } + } + if let Some(v) = s.stream_mem.read().await.get(&id).cloned() { + return Json(v).into_response(); + } + let result = match resolve_stream_payload(&s, &id).await { + Ok(value) => value, + Err(error) => return api_error(StatusCode::BAD_GATEWAY, error), + }; + s.stream_mem + .write() + .await + .insert(id.clone(), result.clone()); + let ss = s.clone(); + let iid = id.clone(); + tokio::spawn(async move { + let _ = start_media_job(&ss, &iid, 1).await; + }); + Json(result).into_response() +} +async fn streams_warm(State(s): State, Query(qp): Query) -> Response { + if let Some(id) = qp.v.filter(|x| valid_id(x)) { + let ss = s.clone(); + tokio::spawn(async move { + if media_row(&ss, &id) + .await + .map(|r| r.status == "ready") + .unwrap_or(false) + { + return; + } + if let Ok(value) = resolve_stream_payload(&ss, &id).await { + ss.stream_mem.write().await.insert(id, value); + } + }); + } + StatusCode::NO_CONTENT.into_response() +} +async fn resolve_stream_payload(s: &AppState, id: &str) -> Result { + let output = Command::new(&s.yt_dlp) + .args([ + "-J", + "--no-warnings", + &format!("https://www.youtube.com/watch?v={id}"), + ]) + .output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + let info: Value = serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?; + Ok(streams_from_info(id, &info)) +} + +fn streams_from_info(id: &str, info: &Value) -> Value { + let meta = json!({"id":id,"title":info["title"].as_str().unwrap_or("(untitled)"),"channel":info["channel"].as_str().or(info["uploader"].as_str()).unwrap_or(""),"channelId":info["channel_id"].as_str().or(info["uploader_id"].as_str()).unwrap_or(""),"channelUrl":info["channel_url"].as_str().or(info["uploader_url"].as_str()).unwrap_or(""),"duration":info["duration"].as_u64().unwrap_or(0),"thumbnail":info["thumbnail"].as_str().unwrap_or(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))}); + let mut audios: Vec<&Value> = info["formats"] + .as_array() + .into_iter() + .flatten() + .filter(|f| { + f["url"].as_str().is_some() + && f["vcodec"].as_str().map(|v| v == "none").unwrap_or(false) + && f["acodec"].as_str().map(|v| v != "none").unwrap_or(false) + }) + .collect(); + audios.sort_by(|a, b| { + b["abr"] + .as_f64() + .unwrap_or(0.0) + .partial_cmp(&a["abr"].as_f64().unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let audio = audios.first(); + let audio_url = audio.map(|f| { + format!( + "/api/play?v={id}&audio=1&f={}", + f["format_id"].as_str().unwrap_or("") + ) + }); + let formats = info["formats"].as_array().cloned().unwrap_or_default(); + let mut quality = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for want_audio in [false, true] { + for f in &formats { + if f["url"].as_str().is_none() { + continue; + } + let video = f["vcodec"].as_str().map(|x| x != "none").unwrap_or(false); + let audio = f["acodec"].as_str().map(|x| x != "none").unwrap_or(false); + if !video || audio != want_audio { + continue; + } + let h = f["height"].as_u64().unwrap_or(0); + if h == 0 || !seen.insert(h) { + continue; + } + quality.push(json!({"label":format!("{h}p"),"height":h,"hasAudio":audio,"url":format!("/api/play?v={id}&f={}",f["format_id"].as_str().unwrap_or("")),"ext":f["ext"].as_str().unwrap_or("mp4")})); + } + } + json!({"ok":true,"data":{"meta":meta,"audioUrl":audio_url,"qualities":quality}}) +} +fn valid_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +#[derive(Clone)] +struct MediaRow { + status: String, + gen: i64, + size: i64, + height: i64, + vcodec: Option, + duration: f64, + meta: Value, + sha256: Option, + optimized: bool, + error: Option, + retry_at: i64, +} +async fn media_row(s: &AppState, id: &str) -> Option { + let db = s.db.clone(); + let id = id.to_string(); + tokio::task::spawn_blocking(move||{ + let c=conn(&db); + c.query_row("SELECT status,gen,size,height,vcodec,duration,meta,sha256,optimized,error,retry_at FROM media_cache WHERE video_id=?1",[id],|r|{ + let meta:String=r.get(6)?; + Ok(MediaRow{status:r.get(0)?,gen:r.get(1)?,size:r.get(2)?,height:r.get(3)?,vcodec:r.get(4)?,duration:r.get(5)?,meta:serde_json::from_str(&meta).unwrap_or(json!({})),sha256:r.get(7)?,optimized:r.get::<_,i64>(8)?!=0,error:r.get(9)?,retry_at:r.get(10)?}) + }).optional().ok().flatten() + }).await.ok().flatten() +} +fn media_path(s: &AppState, id: &str, row: &MediaRow, audio: bool) -> PathBuf { + s.media_dir.join(format!( + "{}.{}.{}", + id, + row.gen, + if audio { "m4a" } else { "mp4" } + )) +} +async fn can_write_media(s: &AppState) -> bool { + s.marker.as_ref().map(|p| p.exists()).unwrap_or(true) +} +async fn start_media_job(s: &AppState, id: &str, priority: i64) -> Result<(), String> { + if !valid_id(id) { + return Err("invalid id".into()); + } + if !can_write_media(s).await { + return Err("media cache volume is offline".into()); + } + if media_row(s, id) + .await + .map(|r| r.status == "ready") + .unwrap_or(false) + { + return Ok(()); + } + { + let mut jobs = s.jobs.lock().unwrap(); + if jobs.contains_key(id) { + return Ok(()); + } + jobs.insert(id.into(), true); + } + let ss = s.clone(); + let video = id.to_string(); + tokio::spawn(async move { + let result = run_media_job(&ss, &video, priority).await; + if let Err(e) = result { + let db = ss.db.clone(); + let vid = video.clone(); + let err = e.clone(); + let _=tokio::task::spawn_blocking(move||{let c=conn(&db);let _=c.execute("INSERT INTO media_cache(video_id,status,error,updated_at,priority) VALUES(?1,'failed',?2,?3,?4) ON CONFLICT(video_id) DO UPDATE SET status='failed',error=excluded.error,updated_at=excluded.updated_at,attempts=media_cache.attempts+1",params![vid,err,now(),priority]);}).await; + } + ss.jobs.lock().unwrap().remove(&video); + }); + Ok(()) +} +async fn run_media_job(s: &AppState, id: &str, priority: i64) -> Result<(), String> { + let old = media_row(s, id).await; + let gen = old.as_ref().map(|x| x.gen + 1).unwrap_or(1); + let base = s.media_dir.join(format!(".{id}.{gen}.work")); + let temp = base.with_extension("mp4"); + let output = Command::new(&s.yt_dlp) + .args([ + "--no-warnings", + "--no-progress", + "--newline", + "-f", + "bv*[vcodec^=avc1][height<=720]+ba[acodec^=mp4a]/b[vcodec^=avc1][height<=720][acodec^=mp4a]/b[ext=mp4][height<=720]", + "--merge-output-format", + "mp4", + "--remux-video", + "mp4", + "-o", + temp.to_str().ok_or("bad media path")?, + &format!("https://www.youtube.com/watch?v={id}"), + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + let probe = Command::new(&s.ffprobe) + .args([ + "-v", + "error", + "-show_entries", + "format=duration:stream=codec_type,codec_name,height", + "-of", + "json", + temp.to_str().ok_or("bad media path")?, + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if !probe.status.success() { + let _ = fs::remove_file(&temp).await; + return Err("ffprobe rejected downloaded media".into()); + } + let pj: Value = serde_json::from_slice(&probe.stdout).map_err(|e| e.to_string())?; + let duration = pj["format"]["duration"] + .as_str() + .and_then(|x| x.parse::().ok()) + .unwrap_or(0.); + let height = pj["streams"] + .as_array() + .and_then(|a| a.iter().find(|x| x["codec_type"] == "video")) + .and_then(|x| x["height"].as_i64()) + .unwrap_or(0); + let vcodec = pj["streams"] + .as_array() + .and_then(|a| a.iter().find(|x| x["codec_type"] == "video")) + .and_then(|x| x["codec_name"].as_str()) + .unwrap_or("h264"); + let acodec = pj["streams"] + .as_array() + .and_then(|a| a.iter().find(|x| x["codec_type"] == "audio")) + .and_then(|x| x["codec_name"].as_str()) + .unwrap_or(""); + if duration <= 0. || vcodec != "h264" || acodec != "aac" { + let _ = fs::remove_file(&temp).await; + return Err("media validation requires H.264/HEVC and AAC".into()); + } + let validate = Command::new(&s.ffmpeg) + .args([ + "-v", + "error", + "-nostdin", + "-i", + temp.to_str().unwrap_or(""), + "-map", + "0", + "-c", + "copy", + "-f", + "null", + "-", + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if !validate.status.success() || !validate.stderr.is_empty() { + let _ = fs::remove_file(&temp).await; + return Err("full demux validation failed".into()); + } + let faststart = base.with_extension("faststart.mp4"); + let remux = Command::new(&s.ffmpeg) + .args([ + "-v", + "error", + "-nostdin", + "-i", + temp.to_str().unwrap_or(""), + "-map", + "0", + "-c", + "copy", + "-movflags", + "+faststart", + "-y", + faststart.to_str().unwrap_or(""), + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if !remux.status.success() { + let _ = fs::remove_file(&temp).await; + let _ = fs::remove_file(&faststart).await; + return Err("faststart remux failed".into()); + } + fs::remove_file(&temp).await.map_err(|e| e.to_string())?; + fs::rename(&faststart, &temp) + .await + .map_err(|e| e.to_string())?; + let size = fs::metadata(&temp).await.map_err(|e| e.to_string())?.len(); + if size < 100 * 1024 { + let _ = fs::remove_file(&temp).await; + return Err("media too small".into()); + } + evict_if_needed(s, size).await; + let dest = s.media_dir.join(format!("{id}.{gen}.mp4")); + fs::rename(&temp, &dest).await.map_err(|e| e.to_string())?; + // Extract the AAC sidecar separately; failure leaves video playback usable. + let audio = s.media_dir.join(format!("{id}.{gen}.m4a")); + let _ = Command::new(&s.ffmpeg) + .args([ + "-v", + "error", + "-nostdin", + "-i", + dest.to_str().unwrap_or(""), + "-vn", + "-c:a", + "copy", + "-movflags", + "+faststart", + "-y", + audio.to_str().unwrap_or(""), + ]) + .output() + .await; + let hash = hash_file(&dest).await.unwrap_or_default(); + let info = Command::new(&s.yt_dlp) + .args([ + "-J", + "--no-warnings", + &format!("https://www.youtube.com/watch?v={id}"), + ]) + .output() + .await + .ok() + .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) + .unwrap_or(json!({})); + let meta = json!({"title":info["title"].as_str().unwrap_or("(untitled)"),"channel":info["channel"].as_str().or(info["uploader"].as_str()).unwrap_or(""),"channelId":info["channel_id"].as_str().unwrap_or(""),"channelUrl":info["channel_url"].as_str().unwrap_or(""),"duration":duration}); + let meta_s = meta.to_string(); + let id2 = id.to_string(); + let db = s.db.clone(); + let vcodec = vcodec.to_string(); + let _=tokio::task::spawn_blocking(move||{let c=conn(&db);c.execute("INSERT INTO media_cache(video_id,status,gen,size,height,vcodec,acodec,duration,meta,priority,updated_at,last_access,sha256) VALUES(?1,'ready',?2,?3,?4,?5,'aac',?6,?7,?8,?9,?9,?10) ON CONFLICT(video_id) DO UPDATE SET status='ready',gen=excluded.gen,size=excluded.size,height=excluded.height,vcodec=excluded.vcodec,acodec='aac',duration=excluded.duration,meta=excluded.meta,priority=excluded.priority,updated_at=excluded.updated_at,error=NULL,sha256=excluded.sha256",params![id2,gen,size as i64,height,vcodec,duration,meta_s,priority,now(),hash])?;Ok::<_,rusqlite::Error>(())}).await; + let _ = tokio::fs::remove_file(&base).await; + let _ = tokio::fs::remove_file(base.with_extension("mp4.part")).await; + if size > s.max_cache_bytes { + return Err("media cache byte budget exceeded".into()); + } + Ok(()) +} +async fn evict_if_needed(s: &AppState, incoming: u64) { + let db = s.db.clone(); + let rows=tokio::task::spawn_blocking(move||{let c=conn(&db);let mut st=c.prepare("SELECT video_id,gen,size FROM media_cache WHERE status='ready' ORDER BY last_access ASC").ok()?;let it=st.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,i64>(1)?,r.get::<_,i64>(2)?))).ok()?;Some(it.flatten().collect::>())}).await.ok().flatten().unwrap_or_default(); + let mut total = rows.iter().map(|x| x.2.max(0) as u64).sum::(); + for (id, gen, size) in rows { + if total.saturating_add(incoming) <= s.max_cache_bytes { + break; + } + let _ = fs::remove_file(s.media_dir.join(format!("{id}.{gen}.mp4"))).await; + let _ = fs::remove_file(s.media_dir.join(format!("{id}.{gen}.m4a"))).await; + total = total.saturating_sub(size.max(0) as u64); + let db = s.db.clone(); + let _ = tokio::task::spawn_blocking(move || { + let c = conn(&db); + let _ = c.execute( + "DELETE FROM media_cache WHERE video_id=?1 AND gen=?2", + params![id, gen], + ); + }) + .await; + } +} +async fn hash_file(path: &Path) -> Result { + let mut f = fs::File::open(path).await?; + let mut h = Sha256::new(); + let mut buf = vec![0; 1024 * 1024]; + loop { + let n = f.read(&mut buf).await?; + if n == 0 { + break; + } + h.update(&buf[..n]) + } + Ok(hex::encode(h.finalize())) +} + +async fn media_status(State(s): State, AxumPath(id): AxumPath) -> Response { + let row = media_row(&s, &id).await; + let out = if let Some(r) = row { + json!({"ok":true,"status":r.status,"gen":r.gen,"sha256":r.sha256,"size":r.size,"height":r.height,"vcodec":r.vcodec,"optimized":r.optimized,"optimizing":false,"error":r.error,"retryAt":r.retry_at}) + } else { + json!({"ok":true,"status":"missing","gen":0,"sha256":Value::Null,"size":0,"height":0,"vcodec":Value::Null,"optimized":false,"optimizing":false,"error":Value::Null,"retryAt":0}) + }; + ([(header::CACHE_CONTROL, "no-store")], Json(out)).into_response() +} +async fn download_prepare(State(s): State, AxumPath(id): AxumPath) -> Response { + if !valid_id(&id) { + return api_error(StatusCode::BAD_REQUEST, "invalid video id"); + } + if let Some(r) = media_row(&s, &id).await { + if r.status == "ready" { + return ([(header::CACHE_CONTROL,"no-store")],Json(json!({"ok":true,"state":"ready","gen":r.gen,"size":r.size,"sha256":r.sha256,"etag":format!("\"{id}.{}\"",r.gen),"ext":"mp4"}))).into_response(); + } + if r.status == "failed" { + return ([(header::CACHE_CONTROL,"no-store")],Json(json!({"ok":true,"state":"legacy","reason":r.meta["error"].as_str().unwrap_or("media fetch failed")}))).into_response(); + } + } + let _ = start_media_job(&s, &id, 0).await; + ( + [(header::CACHE_CONTROL, "no-store")], + Json(json!({"ok":true,"state":"working","status":"queued"})), + ) + .into_response() +} +async fn download_file( + State(s): State, + AxumPath(id): AxumPath, + headers: HeaderMap, + method: Method, +) -> Response { + let Some(r) = media_row(&s, &id).await.filter(|r| r.status == "ready") else { + let _ = start_media_job(&s, &id, 0).await; + return api_error( + StatusCode::SERVICE_UNAVAILABLE, + "server media copy is being prepared", + ); + }; + serve_disk( + &s, + &media_path(&s, &id, &r, false), + "video/mp4", + "no-store", + headers, + method == Method::HEAD, + Some(format!("\"{id}.{}\"", r.gen)), + Some(id.clone()), + Some(format!("attachment; filename=\"{id}.mp4\"")), + r.sha256.as_deref(), + ) + .await +} +async fn media_file( + State(s): State, + AxumPath(id): AxumPath, + Query(qp): Query>, + headers: HeaderMap, + method: Method, +) -> Response { + let Some(r) = media_row(&s, &id).await.filter(|r| r.status == "ready") else { + return api_error(StatusCode::NOT_FOUND, "media is not cached"); + }; + if qp + .get("g") + .and_then(|x| x.parse::().ok()) + .map(|g| g != r.gen) + .unwrap_or(false) + { + return api_error(StatusCode::NOT_FOUND, "media generation not found"); + } + let audio = qp.contains_key("a"); + let path = media_path(&s, &id, &r, audio); + if audio && !path.exists() { + return api_error(StatusCode::NOT_FOUND, "audio sidecar not found"); + }; + serve_disk( + &s, + &path, + if audio { "audio/mp4" } else { "video/mp4" }, + if qp.contains_key("g") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }, + headers, + method == Method::HEAD, + None, + Some(id), + None, + None, + ) + .await +} +#[allow(clippy::too_many_arguments)] +async fn serve_disk( + s: &AppState, + path: &Path, + mime: &str, + cache_control: &str, + headers: HeaderMap, + head: bool, + etag: Option, + touch: Option, + disposition: Option, + sha256: Option<&str>, +) -> Response { + let Ok(meta) = fs::metadata(path).await else { + return api_error(StatusCode::NOT_FOUND, "file missing"); + }; + let total = meta.len(); + let range = headers + .get(header::RANGE) + .and_then(|x| x.to_str().ok()) + .and_then(|r| parse_range(r, total)); + let (status, start, end) = if let Some((a, b)) = range { + (StatusCode::PARTIAL_CONTENT, a, b) + } else { + (StatusCode::OK, 0, total.saturating_sub(1)) + }; + if headers.contains_key(header::RANGE) && range.is_none() { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::empty()) + .unwrap(); + } + let len = if total == 0 { 0 } else { end - start + 1 }; + let mut b = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, mime) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, len.to_string()) + .header(header::CACHE_CONTROL, cache_control); + if let Some(value) = disposition { + b = b.header(header::CONTENT_DISPOSITION, value); + } + if let Some(hash) = sha256 { + b = b.header("X-Content-SHA256", hash).header( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + "X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges", + ); + } + if let Some(e) = etag { + b = b.header(header::ETAG, e) + } + if status == StatusCode::PARTIAL_CONTENT { + b = b.header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) + } + if let Some(id) = touch { + let db = s.db.clone(); + tokio::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + let c = conn(&db); + let _ = c.execute( + "UPDATE media_cache SET last_access=?1,hits=hits+1 WHERE video_id=?2", + params![now(), id], + ); + }) + .await; + }); + } + if head || len == 0 { + return b.body(Body::empty()).unwrap(); + } + let Ok(mut file) = fs::File::open(path).await else { + return api_error(StatusCode::NOT_FOUND, "file missing"); + }; + if file.seek(SeekFrom::Start(start)).await.is_err() { + return api_error(StatusCode::INTERNAL_SERVER_ERROR, "seek failed"); + } + let stream = ReaderStream::with_capacity(file.take(len), 256 * 1024); + b.body(Body::from_stream(stream)).unwrap() +} +fn parse_range(s: &str, total: u64) -> Option<(u64, u64)> { + let v = s.strip_prefix("bytes=")?; + if v.contains(',') { + return None; + } + let (a, b) = v.split_once('-')?; + if a.is_empty() { + let suffix = b.parse::().ok()?.min(total); + if suffix == 0 { + return None; + } + return Some((total - suffix, total.saturating_sub(1))); + } + let start = a.parse::().ok()?; + if start >= total { + return None; + } + let end = if b.is_empty() { + total - 1 + } else { + b.parse::().ok()?.min(total - 1) + }; + if end < start { + return None; + } + Some((start, end)) +} + +#[cfg(test)] +mod tests { + use super::{ensure_media_sha256_column, parse_range}; + use rusqlite::Connection; + + #[test] + fn parses_closed_open_and_suffix_ranges() { + assert_eq!(parse_range("bytes=0-63", 100), Some((0, 63))); + assert_eq!(parse_range("bytes=64-", 100), Some((64, 99))); + assert_eq!(parse_range("bytes=-10", 100), Some((90, 99))); + } + + #[test] + fn rejects_unsatisfiable_and_multi_ranges() { + assert_eq!(parse_range("bytes=100-", 100), None); + assert_eq!(parse_range("bytes=8-2", 100), None); + assert_eq!(parse_range("bytes=0-1,4-5", 100), None); + assert_eq!(parse_range("items=0-1", 100), None); + } + + #[test] + fn adds_digest_to_existing_bun_media_schema() { + let database = Connection::open_in_memory().unwrap(); + database + .execute_batch( + "CREATE TABLE media_cache(video_id TEXT PRIMARY KEY,status TEXT NOT NULL);", + ) + .unwrap(); + ensure_media_sha256_column(&database).unwrap(); + ensure_media_sha256_column(&database).unwrap(); + let has_digest: bool = database + .prepare("PRAGMA table_info(media_cache)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(Result::ok) + .any(|column| column == "sha256"); + assert!(has_digest); + } +} + +#[derive(Deserialize)] +struct PlayQuery { + v: Option, + f: Option, + audio: Option, +} +async fn play( + State(s): State, + Query(qp): Query, + headers: HeaderMap, +) -> Response { + let Some(id) = qp.v else { + return api_error(StatusCode::BAD_REQUEST, "missing video id"); + }; + let args = if qp.audio.as_deref() == Some("1") { + vec!["-f".to_string(), "bestaudio[ext=m4a]/bestaudio".into()] + } else { + vec![ + "-f".into(), + qp.f.map(|f| format!("{f}/best")) + .unwrap_or_else(|| "best".into()), + ] + }; + let output = Command::new(&s.yt_dlp) + .args(args) + .args(["-g", &format!("https://www.youtube.com/watch?v={id}")]) + .output() + .await; + let Ok(output) = output else { + return api_error(StatusCode::BAD_GATEWAY, "could not resolve media"); + }; + let url = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .to_string(); + if url.is_empty() { + return api_error(StatusCode::BAD_GATEWAY, "media URL unavailable"); + } + let mut req = s.client.get(url); + if let Some(range) = headers.get(header::RANGE) { + req = req.header(header::RANGE, range) + } + let Ok(up) = req.send().await else { + return api_error(StatusCode::BAD_GATEWAY, "media upstream failed"); + }; + let mut b = Response::builder() + .status(StatusCode::from_u16(up.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY)); + for key in [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_RANGE, + header::ACCEPT_RANGES, + ] { + if let Some(v) = up.headers().get(&key) { + b = b.header(key, v) + } + } + let stream = up.bytes_stream().map(|r| r.map_err(std::io::Error::other)); + b.body(Body::from_stream(stream)) + .unwrap_or_else(|_| api_error(StatusCode::BAD_GATEWAY, "stream error")) +} + +#[derive(Deserialize)] +struct UserSync { + fingerprint: Option, + #[serde(rename = "appVersion")] + app_version: Option, + playlists: Option, + videos: Option>, +} +async fn user_sync(State(s): State, Json(b): Json) -> Response { + let Some(fp) = b.fingerprint.filter(|x| !x.trim().is_empty()) else { + return api_error(StatusCode::BAD_REQUEST, "missing fingerprint"); + }; + let db = s.db.clone(); + let result=tokio::task::spawn_blocking(move||{ + let c=conn(&db); + c.execute("INSERT INTO users(fingerprint,last_version,last_seen) VALUES(?1,?2,unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET last_version=excluded.last_version,last_seen=excluded.last_seen",params![fp,b.app_version])?; + if let Some(pl)=b.playlists { if pl.is_array() { c.execute("INSERT INTO playlists(fingerprint,data,updated_at) VALUES(?1,?2,unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at",params![fp,pl.to_string()])?; } } + if let Some(videos)=b.videos { + for video in videos.into_iter().filter(|v| v["id"].as_str().is_some()) { + c.execute("INSERT INTO video_history(fingerprint,video_id,title,channel,thumbnail,duration,accessed_at) VALUES(?1,?2,?3,?4,?5,?6,unixepoch()) ON CONFLICT(fingerprint,video_id) DO UPDATE SET title=excluded.title,channel=excluded.channel,thumbnail=excluded.thumbnail,duration=excluded.duration,accessed_at=excluded.accessed_at",params![fp,video["id"].as_str(),video["title"].as_str(),video["channel"].as_str(),video["thumbnail"].as_str(),video["duration"].as_f64().unwrap_or(0.0)])?; + } + c.execute("DELETE FROM video_history WHERE fingerprint=?1 AND video_id NOT IN (SELECT video_id FROM video_history WHERE fingerprint=?1 ORDER BY accessed_at DESC LIMIT 200)",[&fp])?; + } + Ok::<_,rusqlite::Error>(()) + }).await; + if result.is_err() || result.unwrap().is_err() { + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "could not persist user sync", + ); + } + Json(json!({"ok":true})).into_response() +} +async fn user_data( + State(s): State, + Query(qp): Query>, +) -> Response { + let fp = q(&qp, "fp"); + if fp.is_empty() { + return api_error(StatusCode::BAD_REQUEST, "missing fingerprint"); + } + let db = s.db.clone(); + let result=tokio::task::spawn_blocking(move||{let c=conn(&db);let ver=c.query_row("SELECT last_version FROM users WHERE fingerprint=?1",[&fp],|r|r.get::<_,Option>(0)).optional()?.flatten();let pl=c.query_row("SELECT data FROM playlists WHERE fingerprint=?1",[&fp],|r|r.get::<_,String>(0)).optional()?.and_then(|x|serde_json::from_str::(&x).ok()).unwrap_or(json!([]));let mut st=c.prepare("SELECT video_id,title,channel,thumbnail,duration FROM video_history WHERE fingerprint=?1 ORDER BY accessed_at DESC LIMIT 50")?;let rows=st.query_map([&fp],|r|Ok(json!({"id":r.get::<_,String>(0)?,"title":r.get::<_,Option>(1)?,"channel":r.get::<_,Option>(2)?,"thumbnail":r.get::<_,Option>(3)?,"duration":r.get::<_,f64>(4)?})))?;let h=rows.flatten().collect::>();Ok::<_,rusqlite::Error>((ver,pl,h))}).await; + if let Ok(Ok((ver, pl, h))) = result { + Json( + json!({"ok":true,"lastVersion":ver,"playlists":pl,"history":h,"version":s.app_version}), + ) + .into_response() + } else { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "could not read user data", + ) + } +} + +#[derive(Deserialize)] +struct ProfileBody { + name: Option, + data: Option, +} +async fn profile_info( + State(s): State, + Query(qp): Query>, +) -> Response { + let name = q(&qp, "name").trim().to_lowercase(); + if name.is_empty() { + return api_error(StatusCode::BAD_REQUEST, "missing name"); + } + let db = s.db.clone(); + let exists = tokio::task::spawn_blocking(move || { + let c = conn(&db); + c.query_row("SELECT 1 FROM profiles WHERE name=?1", [name], |r| { + r.get::<_, i64>(0) + }) + .optional() + .ok() + .flatten() + .is_some() + }) + .await + .unwrap_or(false); + Json(json!({"ok":true,"exists":exists,"protected":false,"kind":null})).into_response() +} +async fn profile_load( + State(s): State, + Query(qp): Query>, +) -> Response { + let name = q(&qp, "name").trim().to_lowercase(); + if name.is_empty() { + return api_error(StatusCode::BAD_REQUEST, "missing name"); + } + let db = s.db.clone(); + let row = tokio::task::spawn_blocking(move || { + let c = conn(&db); + c.query_row( + "SELECT data,updated_at FROM profiles WHERE name=?1", + [name], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)), + ) + .optional() + .ok() + .flatten() + }) + .await + .unwrap_or(None); + if let Some((data, updated)) = row { + Json(json!({"ok":true,"name":q(&qp,"name").to_lowercase(),"data":serde_json::from_str::(&data).unwrap_or(json!({})),"updatedAt":updated,"protected":false,"kind":null})).into_response() + } else { + api_error(StatusCode::NOT_FOUND, "profile not found") + } +} +async fn profile_create(State(s): State, Json(b): Json) -> Response { + let name = b + .name + .unwrap_or_else(|| format!("profile-{}", now_s())) + .trim() + .to_lowercase(); + if name.len() < 3 + || name.len() > 40 + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return api_error( + StatusCode::BAD_REQUEST, + "invalid name — 3-40 characters: letters, digits, - or _", + ); + } + let data = b.data.unwrap_or(json!({})).to_string(); + if data.len() > 2 * 1024 * 1024 { + return api_error(StatusCode::PAYLOAD_TOO_LARGE, "profile data too large"); + } + let db = s.db.clone(); + let n = name.clone(); + let ins=tokio::task::spawn_blocking(move||{let c=conn(&db);c.execute("INSERT INTO profiles(name,data,created_at,updated_at) VALUES(?1,?2,unixepoch(),unixepoch())",params![n,data])}).await; + if !matches!(ins, Ok(Ok(_))) { + return ( + StatusCode::CONFLICT, + Json(json!({"ok":false,"error":"profile already exists"})), + ) + .into_response(); + } + Json(json!({"ok":true,"name":name,"updatedAt":now_s()})).into_response() +} +async fn profile_save(State(s): State, Json(b): Json) -> Response { + let Some(name) = b + .name + .map(|x| x.trim().to_lowercase()) + .filter(|x| !x.is_empty()) + else { + return api_error(StatusCode::BAD_REQUEST, "missing name"); + }; + let data = b.data.unwrap_or(json!({})).to_string(); + if data.len() > 2 * 1024 * 1024 { + return api_error(StatusCode::PAYLOAD_TOO_LARGE, "profile data too large"); + } + let db = s.db.clone(); + let n = name.clone(); + let r = tokio::task::spawn_blocking(move || { + let c = conn(&db); + c.execute( + "UPDATE profiles SET data=?1,updated_at=unixepoch() WHERE name=?2", + params![data, n], + ) + }) + .await; + if matches!(r, Ok(Ok(1))) { + Json(json!({"ok":true,"updatedAt":now_s()})).into_response() + } else { + api_error(StatusCode::NOT_FOUND, "profile not found") + } +} diff --git a/alt/rust/src/manifest.rs b/alt/rust/src/manifest.rs new file mode 100644 index 0000000..e961a7e --- /dev/null +++ b/alt/rust/src/manifest.rs @@ -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, + pub bytes: BTreeMap, + pub etags: BTreeMap, +} + +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>) -> std::io::Result<()> { + let mut entries = fs::read_dir(dir)?.collect::, _>>()?; + 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) -> 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, + 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)()"#) + .unwrap(); + re.replace(source, format!("$1{assets}$2")).into_owned() +} +fn stamp(source: &str, files: &BTreeMap, 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::>() + .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> { + 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::>()}}})); + let include = def + .get("include") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + let mut groups = Map::new(); + let mut contracts = Map::new(); + let mut membership = BTreeMap::::new(); + if let Some(group_obj) = def.get("groups").and_then(Value::as_object) { + for name in group_obj.keys().cloned().collect::>() { + 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"#; + 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#""#; + 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" + ); + } +}