chore: setup project and scripts

This commit is contained in:
吴璨
2026-09-30 10:16:50 +08:00
commit 52451ae4b5
16 changed files with 816 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal
set "GOTOOLCHAIN=local"
chcp 65001 >nul
cd /d "%~dp0.."
go env -w GOPROXY=https://goproxy.cn,direct
if errorlevel 1 exit /b 1
go env -w GOSUMDB=off
if errorlevel 1 exit /b 1
if not exist dist mkdir dist
set CGO_ENABLED=0
set GOOS=windows
set GOARCH=amd64
go build -trimpath -ldflags="-s -w" -o dist\juku_windows_amd64.exe .
if errorlevel 1 exit /b 1
echo 已生成 dist\juku_windows_amd64.exe
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
export GOTOOLCHAIN=local
cd "$(dirname "${BASH_SOURCE[0]}")/.."
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=off
mkdir -p dist
echo "Building macOS arm64..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_arm64 .
echo "Building macOS amd64..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_amd64 .
echo "Building Linux amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_linux_amd64 .
echo "Building Windows amd64..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_windows_amd64.exe .
echo "Done. Files are in dist/."
+144
View File
@@ -0,0 +1,144 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {chromium, webkit} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const project = path.resolve(process.argv[2] || path.join(__dirname, '..'));
const id = process.argv[3] || 'hongguo:7000000000000000001';
const source = id.split(':')[0];
const group = process.argv[4] || 'hongguo';
const controlID = source + ':7000000000000000002';
const origin = 'http://127.0.0.1:27831';
const remote = 'https://covers.example.org/text-fixture?auth=a%2Fb%2B%3D&expires=1900000000';
const cover = '/api/ui/image?url=' + encodeURIComponent(remote) + '&dramaId=' + encodeURIComponent(id);
const controlCover = '/api/ui/image?url=' + encodeURIComponent('https://covers.example.org/control-text') + '&dramaId=' + encodeURIComponent(controlID);
const ui = path.join(project, 'internal/webui');
let dramas = [
{id, source, title: '海报重试文字测试', cover, coverUrl: cover, totalEpisode: 3},
{id: controlID, source, title: '无关条目文字测试', cover: controlCover, coverUrl: controlCover, totalEpisode: 3},
];
async function until(predicate, message) {
const end = Date.now() + 5000;
while (!predicate()) {
if (Date.now() >= end) throw new Error(message);
await new Promise(resolve => setTimeout(resolve, 20));
}
}
(async () => {
const engine = process.env.JUKU_BROWSER_ENGINE === 'webkit' ? webkit : chromium;
const browser = await engine.launch({headless: true, ...(process.env.JUKU_BROWSER_EXECUTABLE ? {executablePath: process.env.JUKU_BROWSER_EXECUTABLE} : {})});
const context = await browser.newContext({serviceWorkers: 'block', viewport: process.env.JUKU_MOBILE ? {width: 390, height: 844} : {width: 1280, height: 900}});
context.setDefaultTimeout(7000);
const imageAttempts = [], unexpected = [], pageErrors = [];
let refreshes = 0, repairs = 0, releaseRefresh;
const count = target => imageAttempts.filter(address => new URL(address).searchParams.get('url') === target).length;
try {
await context.route('**/*', async route => {
const request = route.request(), address = new URL(request.url());
if (request.resourceType() === 'image' || address.pathname === '/api/ui/image') {
if (address.origin === origin && address.pathname === '/api/ui/image') imageAttempts.push(address.href);
else unexpected.push('image:' + address.origin + address.pathname);
return route.abort();
}
if (address.origin !== origin) {
unexpected.push('external:' + address.origin + address.pathname);
return route.abort();
}
if (address.pathname === '/') return route.fulfill({contentType: 'text/html', body: fs.readFileSync(path.join(ui, 'index.html'), 'utf8')});
if (/^\/assets\/[a-zA-Z0-9_.-]+\.(?:js|css|txt)$/.test(address.pathname)) {
const name = path.basename(address.pathname);
let body = fs.readFileSync(path.join(ui, name), 'utf8');
if (name === 'main.js') body = body.replace('const app = {api, post};', 'const app = {api, post}; window.__coverTestApp = app;');
return route.fulfill({contentType: name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : 'text/plain', body});
}
let result;
switch (address.pathname) {
case '/api/ui/viewer': result = {id: 'a'.repeat(64), ready: true, sources: [group], account: {username: 'fixture', admin: false}}; break;
case '/api/ui/dramas': result = {data: dramas, revision: 1, loadedAt: '2026-09-20T00:00:00Z', sources: {}}; break;
case '/api/ui/tasks': result = {data: [], merges: {}}; break;
case '/api/ui/playback/history': result = {data: []}; break;
case '/api/ui/following': result = {data: dramas.map(drama => ({dramaId: drama.id, source, title: drama.title, saved: true, knownEpisodes: 3, updatedAt: '2026-09-20T00:00:00Z'}))}; break;
case '/api/ui/dramas/refresh': {
refreshes++;
assert.equal(request.postDataJSON().dramaId, id);
await new Promise(resolve => {releaseRefresh = resolve;});
dramas[0] = {...dramas[0], title: '资料已更新文字测试', sortMetadata: {version: 2, checkedAt: '2026-09-20T01:00:00Z'}};
result = {dramaId: id, drama: dramas[0], retryAfter: 300};
break;
}
case '/api/ui/cover/repair': repairs++; result = {dramaId: id, cover, retryAfter: 300}; break;
default: unexpected.push('local:' + address.pathname); return route.abort();
}
return route.fulfill({contentType: 'application/json', body: JSON.stringify(result)});
});
const page = await context.newPage();
page.on('pageerror', error => pageErrors.push(error.message));
await page.goto(origin);
await page.waitForFunction(() => document.documentElement.dataset.ready === 'true');
await page.evaluate(() => {
window.__plays = [];
window.dramaPlayer.open = (id, title) => window.__plays.push({id, title});
window.dramaPlayer.openHistory = window.dramaPlayer.open;
});
const card = page.locator(`.card[data-drama-id="${id}"]`);
await card.locator('.cover.placeholder').waitFor();
assert.equal(refreshes, 0, 'ordinary library reads must not refresh metadata');
const unrelated = count('https://covers.example.org/control-text');
let before = count(remote);
await card.locator('.poster').click();
await until(() => refreshes === 1 && count(remote) > before, 'first click did not retry the failed cover before metadata returned');
assert.equal(await page.evaluate(() => window.__plays.length), 1, 'playback waited for metadata');
await card.locator('.cover.placeholder').waitFor();
before = count(remote);
await card.locator('.poster').click();
await until(() => count(remote) > before, 'pending metadata suppressed the second cover retry');
assert.equal(refreshes, 1);
releaseRefresh();
releaseRefresh = null;
await page.waitForFunction(id => window.__coverTestApp.library.get(id)?.title === '资料已更新文字测试', id);
await card.locator('.cover.placeholder').waitFor();
before = count(remote);
await card.locator('.poster').click();
await until(() => count(remote) > before, 'metadata cooldown suppressed the cover retry');
assert.equal(refreshes, 1);
assert.equal(count('https://covers.example.org/control-text'), unrelated, 'clicking one drama retried an unrelated card');
await card.locator('.card-title').click();
await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败');
await page.locator('#detailPanel [data-close]').click();
before = count(remote);
await card.locator('.card-title').click();
await until(() => count(remote) > before, 'reopening details did not retry its poster');
await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败');
before = count(remote);
await page.locator('#detailPanel .primary-action').click();
await until(() => count(remote) > before, 'playing from details did not retry its poster');
await page.locator('#detailPanel [data-close]').click();
await page.locator('[data-page="following"]:visible').click();
await page.locator('[data-follow-tab="saved"]').click();
const following = page.locator(`.following-row[data-drama-id="${id}"]`);
await following.waitFor();
await page.waitForFunction(id => !document.querySelector(`.following-row[data-drama-id="${id}"] .following-cover img`), id);
before = count(remote);
await following.locator(`[data-focus-key="following-play-${id}"]`).click();
await until(() => count(remote) > before, 'playing from the following list did not retry its poster');
assert.equal(refreshes, 1);
assert.equal(repairs, 0, 'cover retries created redundant metadata repairs');
assert.equal(await page.evaluate(id => window.__coverTestApp.library.get(id).cover, id), cover, 'a transient retry URL replaced the canonical cover');
for (const address of imageAttempts.filter(address => new URL(address).searchParams.has('_retry'))) {
const target = new URL(address);
assert.equal(target.searchParams.get('url'), remote, 'source signature changed');
assert.equal(target.searchParams.get('dramaId'), id, 'drama identity changed');
}
assert.deepEqual(unexpected, []);
assert.deepEqual(pageErrors, []);
console.log(JSON.stringify({project, source, engine: process.env.JUKU_BROWSER_ENGINE || 'chromium', mobile: Boolean(process.env.JUKU_MOBILE), refreshes, repairs, blockedImageAttempts: imageAttempts.length, upstreamRequests: 0, screenshots: 0, result: 'passed'}));
} finally {
releaseRefresh?.();
await context.close();
await browser.close();
}
})().catch(error => {console.error(error); process.exitCode = 1;});
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v docker >/dev/null 2>&1; then
echo "请先安装 Docker。" >&2
exit 1
fi
exec docker build --pull -t "${JUKU_IMAGE:-guoguo-juku:local}" --build-arg "GO_IMAGE=${JUKU_GO_IMAGE:-golang:1.26-bookworm}" --build-arg "RUNTIME_IMAGE=${JUKU_RUNTIME_IMAGE:-debian:bookworm-slim}" "$@" "$project_dir"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
echo "请先安装 Docker 及 Compose v2。" >&2
exit 1
fi
exec docker compose --project-directory "$project_dir" -f "$project_dir/compose.yaml" up -d --build "$@"
+20
View File
@@ -0,0 +1,20 @@
@echo off
setlocal
set "GOTOOLCHAIN=local"
chcp 65001 >nul
title juku 剧库
cd /d "%~dp0.."
where go >nul 2>nul
if not errorlevel 1 (
go env -w GOPROXY=https://goproxy.cn,direct
if errorlevel 1 exit /b 1
go env -w GOSUMDB=off
if errorlevel 1 exit /b 1
)
if not exist "dist\juku_windows_amd64.exe" (
echo 找不到下载器,请先编译或获取包含 dist 的完整版本。
pause
exit /b 1
)
"dist\juku_windows_amd64.exe" %*
if errorlevel 1 pause
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
export GOTOOLCHAIN=local
cd "$(dirname "$0")/.."
if command -v go >/dev/null 2>&1; then
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=off
fi
case "$(uname -m)" in
arm64) binary="./dist/juku_darwin_arm64" ;;
x86_64) binary="./dist/juku_darwin_amd64" ;;
*) echo "不支持的 macOS 架构,请使用 go run ."; exit 1 ;;
esac
if [ ! -x "$binary" ]; then
echo "找不到可执行程序,请先在项目目录运行 ./scripts/build.sh。"
exit 1
fi
exec "$binary" "$@"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
export GOTOOLCHAIN=local
cd "$(dirname "$0")/.."
if command -v go >/dev/null 2>&1; then
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=off
fi
if [ ! -x "./dist/juku_linux_amd64" ]; then
echo "找不到 Linux x64 程序,请先运行 ./scripts/build.sh。"
exit 1
fi
exec ./dist/juku_linux_amd64 "$@"