diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17967c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +config.toml +.env +.env.* +.git +.gitignore +**/node_modules +**/dist +**/target +coverage +*.log diff --git a/Dockerfile b/Dockerfile index 2f6ec06..b1f86b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +FROM node:22-bookworm-slim AS web-build +WORKDIR /app/apps/overlay +COPY apps/overlay/package.json apps/overlay/package-lock.json ./ +RUN npm ci +COPY apps/overlay ./ +RUN npm run build + FROM rust:1.97-bookworm AS rust-build RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -17,5 +24,6 @@ WORKDIR /app # account metadata and short-lived connection tokens. Keep application lifecycle # logs while suppressing dependency INFO output by default. COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server +COPY --from=web-build /app/apps/overlay/dist /app/web EXPOSE 9719 CMD ["/app/lxc-stream-server", "--config", "/app/config.toml"] diff --git a/README.md b/README.md index cc712d2..6f9cae5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 洛星瓷直播转盘 +# 洛星瓷直播弹幕姬 -运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、业务数据库、歌单只读数据库和访问密钥,然后执行: +运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、设置数据库和访问密钥,然后执行: ```sh cp config.toml.example config.toml @@ -9,10 +9,23 @@ docker compose up --build -d 应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。 -应用容器使用 host 网络并监听 `9719`。它通过宿主机 `127.0.0.1:5432` 直连 PostgreSQL:转盘业务使用独立的 `wheel` 数据库,歌单读取使用现有 `lxc_songlist` 数据库。应用会自动创建转盘业务表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。 - -将 `[reply].enabled` 设为 `true` 可启用聊天回复;发送前会从 CookieCloud 读取最新的 Bilibili Cookie,并从其中取得 `bili_jct` CSRF 值。若 Cookie 未同步 `bili_jct`,转盘逻辑仍会正常运行,只会停用聊天回复。 +应用容器使用 host 网络并监听 `9719`。它通过 `[database].url` 连接 PostgreSQL,仅用于持久化 OBS 弹幕姬设置。应用会自动创建所需表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。 服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。 -当前版本只提供 Rust 后端 API 与 WebSocket,不再托管任何前端静态文件或 SPA 路由。未匹配的 HTTP 路径将返回 `404`;新的前端可独立部署并连接 `/ws` 及 `/api/*`。 +## OBS 弹幕姬 + +镜像会在构建阶段编译 `apps/overlay`,最终由 Rust/Axum **同域托管**: + +- `/obs?token=你的_OBS_访问令牌`:透明背景的 OBS 浏览器源。它没有固定画布,按浏览器源的实际宽高自动在窄侧栏、常规卡片和低高度模式之间切换。 +- `/control`:管理员控制台。使用 `admin.password` 登录后可改标题、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效、每卡粒子数量与速度、低性能模式和礼物高亮阈值;保存会实时广播给已打开的 OBS 源。 + +控制台的“复制 OBS 地址”会生成含只读令牌的完整地址。该令牌仅能订阅 `/ws`,不能访问管理或写入接口。常用 OBS 浏览器源尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始;可按实际版面自由拖拽缩放,不会出现 1920×1080 的固定画布留白。 + +每张消息卡片都会从六套花纹组合中稳定选取一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景;连续的新卡会主动避开相同款式,礼物连击更新则保持原样式不跳动。透明消息墙本身不铺设全局装饰背景。粒子数量与速度可在控制台调整;窄尺寸会自动减少粒子,低性能模式会关闭动态粒子。花边 SVG 已本地打包,来源和公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 + +后端会在启动时、之后每十分钟调用 Bilibili 的无需 Cookie 礼物面板接口缓存礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失目录条目会安全降级为直播事件自带的名称与价格。 + +直播弹幕中的普通混排表情和整条大表情会作为安全的文字/图片分段通过 WebSocket 推送。消息自带的图片地址优先;服务还会使用 CookieCloud 的 `SESSDATA` 刷新直播间表情目录,在消息只提供表情唯一标识时补齐图片。图片加载失败时前端会退回原始表情文字。 + +刷新间隔和请求超时可通过 `[gifts]` 调整。`[overlay]` 与 `[overlay.events]` 是某个直播间第一次运行时的展示默认值;在 `/control` 保存后,数据库中的设置优先,因此更新 TOML 不会覆盖管理员已经调好的 OBS 样式。若希望重新采用 TOML 默认值,可删除该房间在 `overlay_settings` 表中的记录后重启。 diff --git a/apps/overlay/index.html b/apps/overlay/index.html new file mode 100644 index 0000000..71a27a5 --- /dev/null +++ b/apps/overlay/index.html @@ -0,0 +1 @@ +洛星瓷弹幕猪
diff --git a/apps/overlay/package-lock.json b/apps/overlay/package-lock.json new file mode 100644 index 0000000..56c49b6 --- /dev/null +++ b/apps/overlay/package-lock.json @@ -0,0 +1,1823 @@ +{ + "name": "lxc-stream-overlay", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lxc-stream-overlay", + "version": "0.1.0", + "dependencies": { + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "@vitejs/plugin-react": "5.0.2", + "typescript": "5.9.2", + "vite": "7.3.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.34", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", + "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", + "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", + "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.3", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.34", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.391", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.391.tgz", + "integrity": "sha512-YmCu4856jkgKT1Nh6fwRdeVrM6Ydf/fBnq51tpmSfX+jOcUMTxh31yH6hjKScRenhB2oDSvA9oooxcpjogPeig==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/apps/overlay/package.json b/apps/overlay/package.json new file mode 100644 index 0000000..f742dcd --- /dev/null +++ b/apps/overlay/package.json @@ -0,0 +1,8 @@ +{ + "name": "lxc-stream-overlay", + "private": true, + "version": "0.1.0", + "scripts": { "build": "tsc -b && vite build", "dev": "vite" }, + "dependencies": { "react": "19.1.1", "react-dom": "19.1.1" }, + "devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" } +} diff --git a/apps/overlay/public/assets/NOTICE.md b/apps/overlay/public/assets/NOTICE.md new file mode 100644 index 0000000..df83647 --- /dev/null +++ b/apps/overlay/public/assets/NOTICE.md @@ -0,0 +1,29 @@ +# Decorative SVG assets + +`floral-divider.svg` is a locally bundled copy of “Floral Divider”. + +- Source: https://openclipart.org/detail/311692/floral-divider +- Author: GDJ +- License: CC0 1.0 / public domain +- Retrieved: 2026-07-15 + +`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by +Paul Bürck”. + +- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg +- Original artist: Paul Bürck, 1899 +- SVG adaptation: Cbaile19 +- License: original public domain; SVG adaptation CC0 1.0 +- Retrieved: 2026-07-15 + +`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by +Paul Bürck”. + +- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg +- Original artist: Paul Bürck, 1899 +- SVG adaptation: Cbaile19 +- License: original public domain; SVG adaptation CC0 1.0 +- Retrieved: 2026-07-15 + +The overlay uses these SVGs as recolorable CSS masks inside message cards and +bundles them locally, so OBS never needs to load the source websites. diff --git a/apps/overlay/public/assets/floral-cluster.svg b/apps/overlay/public/assets/floral-cluster.svg new file mode 100644 index 0000000..21c2e33 --- /dev/null +++ b/apps/overlay/public/assets/floral-cluster.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/overlay/public/assets/floral-divider.svg b/apps/overlay/public/assets/floral-divider.svg new file mode 100644 index 0000000..0ad8f0d --- /dev/null +++ b/apps/overlay/public/assets/floral-divider.svg @@ -0,0 +1,68 @@ + + + + + + + diff --git a/apps/overlay/public/assets/floral-vine.svg b/apps/overlay/public/assets/floral-vine.svg new file mode 100644 index 0000000..2d5e01f --- /dev/null +++ b/apps/overlay/public/assets/floral-vine.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css new file mode 100644 index 0000000..907ac44 --- /dev/null +++ b/apps/overlay/src/control.css @@ -0,0 +1,535 @@ +.overlay { + align-items: flex-start; + background: transparent; +} + +html, +body, +#root, +.copy { + font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif; +} + +.wall { + justify-content: flex-start; +} + +.card { + isolation: isolate; +} + +.card-decor { + --decor-primary-image: url("/assets/floral-divider.svg"); + --decor-primary-position: center 44%; + --decor-primary-size: 92% auto; + --decor-primary-transform: none; + --decor-primary-opacity: .11; + --decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%); + --decor-secondary-image: url("/assets/floral-cluster.svg"); + --decor-secondary-position: right -10px bottom -22px; + --decor-secondary-size: 34% auto; + --decor-secondary-transform: none; + --decor-secondary-opacity: .065; + --decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc); + --decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04)); + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + border-radius: inherit; + pointer-events: none; + user-select: none; +} + +.card-decor::before, +.card-decor::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + background: var(--decor-primary-color); + -webkit-mask-image: var(--decor-primary-image); + mask-image: var(--decor-primary-image); + -webkit-mask-position: var(--decor-primary-position); + mask-position: var(--decor-primary-position); + -webkit-mask-size: var(--decor-primary-size); + mask-size: var(--decor-primary-size); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + opacity: var(--decor-primary-opacity); + transform: var(--decor-primary-transform); + transform-origin: center; + filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28)); +} + +.card-decor::after { + background: var(--decor-secondary-color); + -webkit-mask-image: var(--decor-secondary-image); + mask-image: var(--decor-secondary-image); + -webkit-mask-position: var(--decor-secondary-position); + mask-position: var(--decor-secondary-position); + -webkit-mask-size: var(--decor-secondary-size); + mask-size: var(--decor-secondary-size); + opacity: var(--decor-secondary-opacity); + transform: var(--decor-secondary-transform); +} + +.card-decor-surface { + position: absolute; + inset: 2px; + z-index: 0; + border: 1px solid rgba(157, 255, 232, .14); + border-radius: inherit; + background: var(--decor-surface); + box-shadow: + inset 0 1px rgba(225, 255, 247, .1), + inset 0 -1px rgba(82, 224, 198, .08); +} + +.decor-v1 { + --decor-primary-position: center 68%; + --decor-primary-size: 86% auto; + --decor-primary-transform: rotate(180deg); + --decor-primary-color: linear-gradient(90deg, #ffeab1, #79e2cc 45%, #d7fff3 70%, #e8c6f2); + --decor-secondary-position: right -12px bottom -20px; + --decor-secondary-size: 31% auto; + --decor-secondary-transform: rotate(180deg); + --decor-secondary-opacity: .075; + --decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5); + --decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04)); +} + +.decor-v2 { + --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-position: left -12px bottom -13px; + --decor-primary-size: 73% auto; + --decor-primary-opacity: .105; + --decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9); + --decor-secondary-image: url("/assets/floral-divider.svg"); + --decor-secondary-position: right -22px top -18px; + --decor-secondary-size: 62% auto; + --decor-secondary-opacity: .055; + --decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd); + --decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%); +} + +.decor-v3 { + --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-position: left -15px bottom -15px; + --decor-primary-size: 77% auto; + --decor-primary-transform: scaleX(-1); + --decor-primary-opacity: .115; + --decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0); + --decor-secondary-position: right -12px bottom -20px; + --decor-secondary-size: 32% auto; + --decor-secondary-opacity: .07; + --decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad); + --decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%); +} + +.decor-v4 { + --decor-primary-image: url("/assets/floral-cluster.svg"); + --decor-primary-position: left -18px bottom -28px; + --decor-primary-size: 45% auto; + --decor-primary-transform: rotate(-5deg); + --decor-primary-opacity: .09; + --decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4); + --decor-secondary-position: left -15px bottom -25px; + --decor-secondary-size: 37% auto; + --decor-secondary-transform: rotate(180deg); + --decor-secondary-opacity: .065; + --decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6); + --decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%); +} + +.decor-v5 { + --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-position: center top -17px; + --decor-primary-size: 90% auto; + --decor-primary-transform: scaleY(-1); + --decor-primary-opacity: .095; + --decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8); + --decor-secondary-image: url("/assets/floral-divider.svg"); + --decor-secondary-position: left 18% top -15px; + --decor-secondary-size: 55% auto; + --decor-secondary-opacity: .06; + --decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0); + --decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035)); +} + +.card::before { + z-index: 1; + pointer-events: none; +} + +.card > .gift-art, +.card > .copy, +.card > .particles { + position: relative; + z-index: 3; +} + +.card-particle-layer { + position: absolute; + inset: 0; + z-index: 2; + opacity: .5; + transition: opacity .3s ease; +} + +.decor-v1 .card-particle-layer, +.decor-v3 .card-particle-layer { + transform: scaleX(-1); +} + +.decor-v2 .card-particle-layer { + transform: scaleY(-1); +} + +.decor-v5 .card-particle-layer { + transform: rotate(180deg); +} + +.card.expanded .card-particle-layer { + opacity: 1; +} + +.card-particle { + --particle-size: 8px; + position: absolute; + display: block; + width: var(--particle-size); + height: var(--particle-size); + opacity: 0; + will-change: transform, opacity; + animation: card-sparkle var(--particle-duration, 4000ms) ease-in-out infinite; +} + +.card-particle.star { + background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff); + clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%); + filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9)); +} + +.card-particle.floret { + border-radius: 50%; + background: + radial-gradient(circle at 50% 17%, #ffd7e5 0 19%, transparent 22%), + radial-gradient(circle at 82% 50%, #ffd7e5 0 19%, transparent 22%), + radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%), + radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%), + radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%); + filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72)); +} + +.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; } +.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; } +.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; } +.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; } +.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; } +.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; } +.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; } +.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; } +.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; } +.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; } +.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; } +.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; } + +@keyframes card-sparkle { + 0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); } + 38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); } + 68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); } +} + +.narrow .card-particle-layer > :nth-child(n+9), +.short .card-particle-layer > :nth-child(n+7) { + display: none; +} + +.low-motion .card-particle-layer { + display: none; +} + +.wall header { + max-width: 100%; + padding: 9px 14px; + font-size: var(--font-title, 25px); +} + +.wall header span { + min-width: 0; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +.wall:not(.awake) header, +.wall header span, +.copy, +.copy b, +.copy span, +.copy em { + opacity: 1; +} + +.copy { + font-size: var(--font-body, 25px); +} + +.copy span { + color: #e7fff9; +} + +.danmaku-content { + line-height: 1.35; +} + +.card.danmaku .copy .danmaku-content .danmaku-text { + display: inline; + white-space: inherit; +} + +.danmaku-emoticon { + display: inline-block; + width: auto; + height: 1.4em; + max-width: 6em; + margin-inline: .08em; + object-fit: contain; + vertical-align: -.32em; + opacity: 1; + filter: none; +} + +.danmaku-emoticon.standalone { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 4.8em; + margin: .12em 0; + vertical-align: top; +} + +.card.danmaku.compact .danmaku-emoticon.standalone { + display: inline-block; + max-width: 5em; + max-height: 2.2em; + margin: 0 .1em; + vertical-align: -.55em; +} + +.copy b, +.copy span { + max-width: none; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +.card.danmaku { + isolation: isolate; + transform-origin: center center; + transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease; +} + +.card.danmaku.expanded { + min-height: 92px; + padding: 14px; + border-color: rgba(133, 255, 232, .58); + background: + linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)), + linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78)); + box-shadow: + inset 12px 0 18px -15px rgba(142, 255, 230, .95), + inset -12px 0 18px -15px rgba(142, 255, 230, .95), + 0 10px 28px rgba(0, 15, 25, .32); + animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both; +} + +.card.danmaku.expanded::after { + content: ""; + position: absolute; + inset-block: 4px; + inset-inline: -1px; + z-index: 2; + pointer-events: none; + border-inline: 4px solid rgba(123, 238, 214, .72); + border-radius: 11px; + background: + linear-gradient(90deg, + rgba(201, 255, 240, .32) 0, + rgba(63, 180, 165, .26) 5px, + transparent 5px, + transparent calc(100% - 5px), + rgba(63, 180, 165, .26) calc(100% - 5px), + rgba(201, 255, 240, .32) 100%); + box-shadow: + inset 6px 0 7px -7px rgba(216, 255, 246, .72), + inset -6px 0 7px -7px rgba(216, 255, 246, .72), + -2px 0 0 rgba(7, 46, 53, .72), + 2px 0 0 rgba(7, 46, 53, .72); + animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both; +} + +.card.danmaku.expanded .copy { + z-index: 3; + width: 100%; + padding-inline: 6px; + flex-direction: column; + align-items: stretch; + gap: 8px; + font-size: var(--font-expanded, 36px); +} + +.card.danmaku.expanded .copy b { + color: #f0fffb; + font-size: .78em; + letter-spacing: .06em; +} + +.card.danmaku.expanded .copy span { + display: block; + overflow: visible; + white-space: normal; + overflow-wrap: anywhere; + -webkit-line-clamp: unset; +} + +.card.danmaku.compact { + min-height: 38px; + padding: 5px 10px; + border-radius: 10px; + background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48)); +} + +.card.danmaku.compact .copy { + width: 100%; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + font-size: var(--font-compact, 21px); +} + +.card.danmaku.compact .copy b { + max-width: none; + flex: 0 0 auto; + color: #aeece1; +} + +.card.danmaku.compact .copy span { + min-width: 0; + flex: 1 1 auto; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +.short .cards .card:nth-child(n+4) { + display: flex; +} + +@keyframes scroll-unfurl { + from { + clip-path: inset(0 49.5% round 18px); + } + to { + clip-path: inset(0 round 14px); + } +} + +@keyframes scroll-rails-open { + from { + transform: scaleX(.025); + } + to { + transform: scaleX(1); + } +} + +@keyframes arrive { + from { transform: translateX(38px) scale(.96); } + to { transform: none; } +} + +.overlay.narrow { padding: 6px; } + +.narrow .card.danmaku.expanded { + min-height: 82px; + padding: 12px; +} + +.gift-art { + width: 96px; + height: 96px; +} + +.narrow .gift-art { + width: 72px; + height: 72px; +} + +.narrow .gift.featured .gift-art { + width: 100px; + height: 100px; +} + +.buttons, +.preset-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +.preset-buttons button { + display: flex; + flex-direction: column; + gap: 2px; +} + +.preset-buttons small { + font-weight: normal; + opacity: .72; +} + +.preset-buttons .active { + box-shadow: 0 0 0 2px rgba(137, 255, 226, .28); +} + +.control .obs-address { + margin-top: 16px; +} + +.control .obs-address input { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; +} + +.control .success { + color: #77f5c9; +} + +.preview-viewport { + max-width: 100%; + max-height: 75vh; + overflow: auto; + margin-top: 14px; + border: 1px solid rgba(89, 183, 173, .25); + background: #04131b; +} + +.preview-frame { + max-width: none; + height: auto; + resize: both; + overflow: hidden; +} + +@media (max-width: 720px) { + .preview-viewport { max-height: 560px; } +} diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx new file mode 100644 index 0000000..90ee944 --- /dev/null +++ b/apps/overlay/src/main.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import './style.css' +import './control.css' + +type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number } +type Envelope = { id:string; type:string; payload:any } +type Item = Envelope & { key:string; received:number; decorVariant:number } +type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean } +const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 } +const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const +const decorVariantCount=6 + +function stableHash(value:string) { let hash=2166136261; for(let index=0;index>>0 } +function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount } +function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return } + +function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` } +function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) } +function useEvents(disabled=false) { + const [settings,setSettings]=useState(defaults); const [items,setItems]=useState([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings) + useEffect(()=>{settingsRef.current=settings},[settings]) + useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0 + const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } } + open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()} + },[disabled]) + useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible]) + return {settings,items,connected,setItems} +} +function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'} +const eventRenderers:Recordstring>={ + 'live.enter':()=> '踏入了云台','live.superchat':p=>p.message, + 'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间' +} +function DanmakuEmoticon({segment}:{segment:Extract}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}; return {segment.text}setFailed(true)}/> } +function DanmakuBody({payload}:{payload:any}) { const segments=Array.isArray(payload.segments)?payload.segments as DanmakuSegment[]:undefined; if(!segments?.length)return <>{payload.text||''}; return <>{segments.map((segment,index)=>segment.type==='emoticon'&&segment.url?:{segment.text})} } +function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?:eventRenderers[item.type]?.(p)||'送来了一份互动'; + return
+ + {gift&&
{gift.animationUrl||gift.imageUrl?{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:✦}
} +
{v.name||'直播间观众'}{body}{gift?.priceCny>0&&¥ {Number(gift.priceCny).toFixed(2)}}
{tier==='featured'&&
✦ ✧ ✦
} +
} +function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState(); const fontFactor=settings.fontScale/100 + useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[]) + useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems]) + useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape]) + return
{settings.title}
{items.map(item=>)}
} +function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})} +async function copyToClipboard(text:string){ + if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}} + const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select() + let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()} + return copied +} +const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}] +function Control(){ + const [password,setPassword]=useState(''); const [settings,setSettings]=useState(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1]) + const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[]) + useEffect(()=>{load().catch(()=>{})},[load]) + const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()} + const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)} + const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}} + if(!settings)return

弹幕猪控制台

setPassword(e.target.value)}/>{error&&

{error}

}
+ const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value}) + const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'} + return

青玉弹幕姬

改动会立即同步到所有 OBS 浏览器源。

{(Object.keys(labels) as (keyof typeof labels)[]).map(k=>)}
{obsAddress&&}{message&&

{message}

}{error&&

{error}

}

自适应预览

选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。

{previewPresets.map(size=>)}
+} +const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?:); diff --git a/apps/overlay/src/style.css b/apps/overlay/src/style.css new file mode 100644 index 0000000..d3ea376 --- /dev/null +++ b/apps/overlay/src/style.css @@ -0,0 +1 @@ +*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.wall header{align-self:flex-end;display:flex;gap:8px;align-items:center;padding:8px 14px;border:1px solid rgba(144,255,240,.33);border-radius:999px;background:linear-gradient(110deg,rgba(13,47,63,.72),rgba(29,115,107,.44));backdrop-filter:blur(12px);letter-spacing:.08em;font-size:clamp(12px,2.5vw,17px);transition:.5s}.wall:not(.awake) header{opacity:.72}.wall header i{width:7px;height:7px;border-radius:50%;background:#55736f}.wall header i.online{background:#74ffd9;box-shadow:0 0 10px #4bffc7}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}} diff --git a/apps/overlay/tsconfig.json b/apps/overlay/tsconfig.json new file mode 100644 index 0000000..0b4fc59 --- /dev/null +++ b/apps/overlay/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, + "include": ["src"] +} diff --git a/apps/overlay/vite.config.ts b/apps/overlay/vite.config.ts new file mode 100644 index 0000000..ecc7ca4 --- /dev/null +++ b/apps/overlay/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +export default defineConfig({ plugins: [react()] }) diff --git a/apps/server-rust/Cargo.lock b/apps/server-rust/Cargo.lock index ad4ba5c..0f3a7e2 100644 --- a/apps/server-rust/Cargo.lock +++ b/apps/server-rust/Cargo.lock @@ -1307,6 +1307,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1841,6 +1847,7 @@ dependencies = [ "tokio", "tokio-postgres", "toml", + "tower-http", "tracing", "tracing-subscriber", "uuid", @@ -1898,6 +1905,16 @@ 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 = "minimal-lexical" version = "0.2.1" @@ -3660,10 +3677,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http 1.4.2", "http-body 1.1.0", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -3815,6 +3841,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/apps/server-rust/Cargo.toml b/apps/server-rust/Cargo.toml index f1e48b9..515ee23 100644 --- a/apps/server-rust/Cargo.toml +++ b/apps/server-rust/Cargo.toml @@ -22,6 +22,7 @@ sha2 = "0.10" tokio = { version = "1", features = ["full"] } tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] } toml = "0.8" +tower-http = { version = "0.6", features = ["fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/apps/server-rust/migrations/001_initial.sql b/apps/server-rust/migrations/001_initial.sql index 3c72302..dbe6c9e 100644 --- a/apps/server-rust/migrations/001_initial.sql +++ b/apps/server-rust/migrations/001_initial.sql @@ -1,19 +1,3 @@ -CREATE TABLE IF NOT EXISTS viewer_accounts ( - scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, display_name TEXT NOT NULL, - points INTEGER NOT NULL DEFAULT 0 CHECK(points >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY(scope, room_id, uid) -); -CREATE TABLE IF NOT EXISTS point_ledger ( - id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, delta INTEGER NOT NULL, - reason TEXT NOT NULL, source_event_id TEXT UNIQUE, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE TABLE IF NOT EXISTS wheel_spins ( - id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, category TEXT NOT NULL, - song_id INTEGER NOT NULL, song_title TEXT NOT NULL, fallback BOOLEAN NOT NULL, cost INTEGER NOT NULL, balance_after INTEGER NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS viewer_accounts_name_idx ON viewer_accounts(scope, room_id, display_name); -CREATE INDEX IF NOT EXISTS point_ledger_recent_idx ON point_ledger(scope, room_id, created_at DESC); CREATE TABLE IF NOT EXISTS live_session_outbox ( id BIGSERIAL PRIMARY KEY, streamer_uid BIGINT NOT NULL, diff --git a/apps/server-rust/migrations/002_overlay_settings.sql b/apps/server-rust/migrations/002_overlay_settings.sql new file mode 100644 index 0000000..de46cc1 --- /dev/null +++ b/apps/server-rust/migrations/002_overlay_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS overlay_settings ( + room_id TEXT PRIMARY KEY, + settings JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/apps/server-rust/src/main.rs b/apps/server-rust/src/main.rs index de6aa69..3128798 100644 --- a/apps/server-rust/src/main.rs +++ b/apps/server-rust/src/main.rs @@ -1,140 +1,1285 @@ -use std::{env, fs, path::PathBuf, sync::{Arc, atomic::{AtomicBool, Ordering}}}; +use std::{env, fs, path::PathBuf, sync::Arc}; -use axum::{extract::{ws::{Message, WebSocket, WebSocketUpgrade}, Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use axum::{ + Json, Router, + extract::{ + Query, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{any, get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient}; use futures_channel::mpsc; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sha2::Sha256; -use tokio::{sync::{broadcast, Mutex}, task}; +use tokio::{ + sync::{Mutex, RwLock, broadcast}, + task, +}; use tokio_postgres::{Client, NoTls}; +use tower_http::services::{ServeDir, ServeFile}; use tracing::{error, info, warn}; use uuid::Uuid; type HmacSha256 = Hmac; -const WHEEL_COST: i32 = 150; + +mod overlay; +use overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, OverlaySettings, normalize_image_url}; #[derive(Clone)] -struct Config { port: u16, room_id: String, database_url: String, songlist_database_url: String, cookiecloud_host: String, cookiecloud_key: String, cookiecloud_password: String, admin_password: String, session_secret: String, obs_access_token: String, reply_enabled: bool, log_filter: String } +struct Config { + port: u16, + room_id: String, + database_url: String, + cookiecloud_host: String, + cookiecloud_key: String, + cookiecloud_password: String, + admin_password: String, + session_secret: String, + obs_access_token: String, + log_filter: String, + gift_refresh_seconds: u64, + gift_request_timeout_seconds: u64, + emoticon_refresh_seconds: u64, + emoticon_request_timeout_seconds: u64, + overlay_defaults: OverlaySettings, +} #[derive(Deserialize)] -struct FileConfig { connection: ConnectionConfig, #[serde(default)] server: ServerConfig, database: DatabaseConfig, songlist: SonglistConfig, cookiecloud: CookieCloudConfig, admin: AdminConfig, obs: ObsConfig, #[serde(default)] reply: ReplyConfig, #[serde(default)] logging: LoggingConfig } -#[derive(Deserialize)] struct ConnectionConfig { room_id: String } -#[derive(Deserialize, Default)] struct ServerConfig { port: Option } -#[derive(Deserialize)] struct DatabaseConfig { url: String } -#[derive(Deserialize)] struct SonglistConfig { database_url: String } -#[derive(Deserialize)] struct CookieCloudConfig { host: String, key: String, password: String } -#[derive(Deserialize)] struct AdminConfig { password: String, session_secret: String } -#[derive(Deserialize)] struct ObsConfig { access_token: String } -#[derive(Deserialize, Default)] struct ReplyConfig { enabled: Option } -#[derive(Deserialize, Default)] struct LoggingConfig { filter: Option } +struct FileConfig { + connection: ConnectionConfig, + #[serde(default)] + server: ServerConfig, + database: DatabaseConfig, + cookiecloud: CookieCloudConfig, + admin: AdminConfig, + obs: ObsConfig, + #[serde(default)] + gifts: GiftsConfig, + #[serde(default)] + emoticons: EmoticonsConfig, + #[serde(default)] + overlay: OverlayFileConfig, + #[serde(default)] + logging: LoggingConfig, +} +#[derive(Deserialize)] +struct ConnectionConfig { + room_id: String, +} +#[derive(Deserialize, Default)] +struct ServerConfig { + port: Option, +} +#[derive(Deserialize)] +struct DatabaseConfig { + url: String, +} +#[derive(Deserialize)] +struct CookieCloudConfig { + host: String, + key: String, + password: String, +} +#[derive(Deserialize)] +struct AdminConfig { + password: String, + session_secret: String, +} +#[derive(Deserialize)] +struct ObsConfig { + access_token: String, +} +#[derive(Deserialize, Default)] +struct GiftsConfig { + refresh_interval_seconds: Option, + request_timeout_seconds: Option, +} +#[derive(Deserialize, Default)] +struct EmoticonsConfig { + refresh_interval_seconds: Option, + request_timeout_seconds: Option, +} +#[derive(Deserialize, Default)] +struct OverlayFileConfig { + title: Option, + font_scale: Option, + max_visible: Option, + collapse_after_seconds: Option, + unfold_duration_ms: Option, + motion_intensity: Option, + particle_count: Option, + particle_speed: Option, + low_performance_mode: Option, + high_value_threshold: Option, + featured_value_threshold: Option, + #[serde(default)] + events: OverlayEventsConfig, +} +#[derive(Deserialize, Default)] +struct OverlayEventsConfig { + danmaku: Option, + enter: Option, + gift: Option, + superchat: Option, + guard: Option, + like: Option, + share: Option, +} +#[derive(Deserialize, Default)] +struct LoggingConfig { + filter: Option, +} impl Config { fn config_path() -> Result { let mut args = env::args_os().skip(1); let mut path = PathBuf::from("config.toml"); while let Some(arg) = args.next() { - if arg == "--config" { path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?); } - else { return Err(format!("Unknown argument: {:?}; use --config ", arg)); } + if arg == "--config" { + path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?); + } else { + return Err(format!("Unknown argument: {:?}; use --config ", arg)); + } } Ok(path) } fn load() -> Result { let path = Self::config_path()?; - let source = fs::read_to_string(&path).map_err(|e| format!("Cannot read configuration {}: {e}", path.display()))?; - let file: FileConfig = toml::from_str(&source).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))?; - Ok(Self { port: file.server.port.unwrap_or(9719), room_id: file.connection.room_id, database_url: file.database.url, songlist_database_url: file.songlist.database_url, cookiecloud_host: file.cookiecloud.host, cookiecloud_key: file.cookiecloud.key, cookiecloud_password: file.cookiecloud.password, admin_password: file.admin.password, session_secret: file.admin.session_secret, obs_access_token: file.obs.access_token, reply_enabled: file.reply.enabled.unwrap_or(false), log_filter: file.logging.filter.unwrap_or_else(|| "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()) }) + let source = fs::read_to_string(&path) + .map_err(|e| format!("Cannot read configuration {}: {e}", path.display()))?; + let file: FileConfig = toml::from_str(&source) + .map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))?; + let overlay_default = OverlaySettings::default(); + let overlay_defaults = OverlaySettings { + title: file.overlay.title.unwrap_or(overlay_default.title), + font_scale: file + .overlay + .font_scale + .unwrap_or(overlay_default.font_scale), + show_danmaku: file + .overlay + .events + .danmaku + .unwrap_or(overlay_default.show_danmaku), + show_enter: file + .overlay + .events + .enter + .unwrap_or(overlay_default.show_enter), + show_gift: file + .overlay + .events + .gift + .unwrap_or(overlay_default.show_gift), + show_superchat: file + .overlay + .events + .superchat + .unwrap_or(overlay_default.show_superchat), + show_guard: file + .overlay + .events + .guard + .unwrap_or(overlay_default.show_guard), + show_like: file + .overlay + .events + .like + .unwrap_or(overlay_default.show_like), + show_share: file + .overlay + .events + .share + .unwrap_or(overlay_default.show_share), + max_visible: file + .overlay + .max_visible + .unwrap_or(overlay_default.max_visible), + collapse_after_seconds: file + .overlay + .collapse_after_seconds + .unwrap_or(overlay_default.collapse_after_seconds), + unfold_duration_ms: file + .overlay + .unfold_duration_ms + .unwrap_or(overlay_default.unfold_duration_ms), + motion_intensity: file + .overlay + .motion_intensity + .unwrap_or(overlay_default.motion_intensity), + particle_count: file + .overlay + .particle_count + .unwrap_or(overlay_default.particle_count), + particle_speed: file + .overlay + .particle_speed + .unwrap_or(overlay_default.particle_speed), + low_performance_mode: file + .overlay + .low_performance_mode + .unwrap_or(overlay_default.low_performance_mode), + high_value_threshold: file + .overlay + .high_value_threshold + .unwrap_or(overlay_default.high_value_threshold), + featured_value_threshold: file + .overlay + .featured_value_threshold + .unwrap_or(overlay_default.featured_value_threshold), + } + .sanitize(); + Ok(Self { + port: file.server.port.unwrap_or(9719), + room_id: file.connection.room_id, + database_url: file.database.url, + cookiecloud_host: file.cookiecloud.host, + cookiecloud_key: file.cookiecloud.key, + cookiecloud_password: file.cookiecloud.password, + admin_password: file.admin.password, + session_secret: file.admin.session_secret, + obs_access_token: file.obs.access_token, + gift_refresh_seconds: file + .gifts + .refresh_interval_seconds + .unwrap_or(600) + .clamp(60, 86_400), + gift_request_timeout_seconds: file + .gifts + .request_timeout_seconds + .unwrap_or(10) + .clamp(2, 120), + emoticon_refresh_seconds: file + .emoticons + .refresh_interval_seconds + .unwrap_or(600) + .clamp(60, 86_400), + emoticon_request_timeout_seconds: file + .emoticons + .request_timeout_seconds + .unwrap_or(10) + .clamp(2, 120), + overlay_defaults, + log_filter: file.logging.filter.unwrap_or_else(|| { + "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into() + }), + }) } } #[derive(Clone)] -struct AppState { config: Config, events: broadcast::Sender, reply_enabled: Arc, source: Arc> } +struct AppState { + config: Config, + events: broadcast::Sender, + source: Arc>, + overlay_settings: Arc>, + gift_catalog: GiftCatalog, + emoticon_catalog: EmoticonCatalog, +} #[derive(Clone, Default)] -struct SourceStatus { connected: bool, cookie_cloud: bool, detail: String } +struct SourceStatus { + connected: bool, + cookie_cloud: bool, + detail: String, +} #[derive(Serialize, Clone)] -struct Viewer { uid: String, name: String } -#[derive(Clone)] -enum Incoming { Enter { viewer: Viewer }, Danmaku { viewer: Viewer, text: String }, Gift { viewer: Viewer, name: String, battery: i32, quantity: i32, event_id: String }, Event { kind: String, payload: Value } } +struct Viewer { + uid: String, + name: String, +} +#[derive(Clone, Debug)] +struct DanmakuEmoticonHint { + text: String, + unique: Option, + url: Option, + width: Option, + height: Option, + is_dynamic: bool, + bulge_display: bool, + standalone: bool, +} -#[derive(Deserialize)] struct Login { password: String } -#[derive(Deserialize)] struct ToggleReply { enabled: bool } -#[derive(Deserialize)] struct ViewerQuery { search: Option } -#[derive(Deserialize)] struct WsQuery { token: Option } -#[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] -enum TestEvent { Enter { uid: String, name: String }, Danmaku { uid: String, name: String, text: String }, Gift { uid: String, name: String, #[serde(rename = "giftName")] gift_name: String, battery: i32, quantity: i32 } } +#[derive(Clone, Debug, Serialize)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] +enum DanmakuSegment { + Text { + text: String, + }, + Emoticon { + text: String, + unique: Option, + url: String, + width: Option, + height: Option, + is_dynamic: bool, + standalone: bool, + }, +} + +#[derive(Clone, Debug)] +struct ResolvedDanmakuEmoticon { + text: String, + unique: Option, + url: String, + width: Option, + height: Option, + is_dynamic: bool, + standalone: bool, +} + +#[derive(Clone)] +enum Incoming { + Enter { + viewer: Viewer, + }, + Danmaku { + viewer: Viewer, + text: String, + emoticons: Vec, + }, + Gift { + viewer: Viewer, + name: String, + gift_id: Option, + battery: i32, + quantity: i32, + event_id: String, + }, + Combo { + viewer: Viewer, + name: String, + gift_id: Option, + battery: i32, + quantity: i32, + combo_id: String, + }, + Event { + kind: String, + payload: Value, + }, +} + +#[derive(Deserialize)] +struct Login { + password: String, +} +#[derive(Deserialize)] +struct WsQuery { + token: Option, +} +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum TestEvent { + Enter { + uid: String, + name: String, + }, + Danmaku { + uid: String, + name: String, + text: String, + }, + Gift { + uid: String, + name: String, + #[serde(rename = "giftName")] + gift_name: String, + #[serde(rename = "giftId")] + gift_id: Option, + battery: i32, + quantity: i32, + }, +} #[tokio::main] async fn main() { let config = Config::load().unwrap_or_else(|error| panic!("Configuration error: {error}")); let log_filter = tracing_subscriber::EnvFilter::new(config.log_filter.clone()); - tracing_subscriber::fmt().with_env_filter(log_filter).json().init(); + tracing_subscriber::fmt() + .with_env_filter(log_filter) + .json() + .init(); let (events, _) = broadcast::channel(256); - let state = AppState { reply_enabled: Arc::new(AtomicBool::new(config.reply_enabled)), source: Arc::new(Mutex::new(SourceStatus { detail: "Starting blivedm_rs listener".into(), ..Default::default() })), config, events }; - migrate(&state.config.database_url).await.expect("database migration failed"); + let state = AppState { + source: Arc::new(Mutex::new(SourceStatus { + detail: "Starting blivedm_rs listener".into(), + ..Default::default() + })), + overlay_settings: Arc::new(RwLock::new(config.overlay_defaults.clone())), + gift_catalog: GiftCatalog::default(), + emoticon_catalog: EmoticonCatalog::default(), + config, + events, + }; + migrate(&state.config.database_url) + .await + .expect("database migration failed"); + let settings = overlay::load_settings( + &state.config.database_url, + &state.config.room_id, + state.config.overlay_defaults.clone(), + ) + .await + .expect("overlay settings load failed"); + *state.overlay_settings.write().await = settings; + if let Err(error) = state + .gift_catalog + .refresh( + &state.config.room_id, + state.config.gift_request_timeout_seconds, + ) + .await + { + warn!(%error, "initial gift catalog refresh failed; retrying in the background"); + } + state.gift_catalog.clone().spawn_refresh( + state.config.room_id.clone(), + state.config.gift_refresh_seconds, + state.config.gift_request_timeout_seconds, + ); + if let Err(error) = refresh_emoticon_catalog(&state).await { + warn!(%error, "initial emoticon catalog refresh failed; message metadata will remain available"); + } + spawn_emoticon_catalog_refresh(state.clone()); spawn_live_listener(state.clone()); let app = Router::new() - .route("/health", get(health)).route("/ws", get(ws)) - .route("/api/auth/login", post(login)).route("/api/auth/logout", post(logout)) - .route("/api/admin/status", get(status)).route("/api/admin/viewers", get(viewers)).route("/api/admin/ledger", get(ledger)) - .route("/api/admin/reconnect", post(reconnect)).route("/api/admin/reply", post(toggle_reply)) - .route("/api/test/event", post(test_event)).with_state(state.clone()); + .route("/health", get(health)) + .route("/ws", get(ws)) + .route("/obs", get(overlay_page)) + .route("/control", get(overlay_page)) + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/admin/status", get(status)) + .route("/api/admin/reconnect", post(reconnect)) + .route( + "/api/admin/overlay-settings", + get(get_overlay_settings).put(put_overlay_settings), + ) + .route("/api/admin/obs-url", get(obs_url)) + .route("/api/test/event", post(test_event)) + .route("/api/{*path}", any(api_not_found)) + .fallback_service( + ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")), + ) + .with_state(state.clone()); let addr = format!("0.0.0.0:{}", state.config.port); - let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind failed"); - info!(%addr, "Rust backend listening"); axum::serve(listener, app).await.expect("server failed"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("bind failed"); + info!(%addr, "Rust backend listening"); + axum::serve(listener, app).await.expect("server failed"); } -async fn migrate(url: &str) -> Result<(), String> { let client = connect(url).await?; client.batch_execute(include_str!("../migrations/001_initial.sql")).await.map_err(|e| e.to_string()) } -async fn connect(url: &str) -> Result { let (client, connection) = tokio_postgres::connect(url, NoTls).await.map_err(|e| e.to_string())?; tokio::spawn(async move { if let Err(e) = connection.await { warn!(error = %e, "postgres connection ended"); } }); Ok(client) } -fn session(config: &Config) -> String { let mut mac = HmacSha256::new_from_slice(config.session_secret.as_bytes()).expect("hmac key"); mac.update(b"admin"); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) } -fn cookie(headers: &HeaderMap, name: &str) -> Option { headers.get("cookie")?.to_str().ok()?.split(';').find_map(|part| part.trim().split_once('=').filter(|(k, _)| *k == name).map(|(_, v)| v.to_owned())) } -fn admin(state: &AppState, headers: &HeaderMap) -> bool { cookie(headers, "lxc_session").is_some_and(|v| v == session(&state.config)) } -fn unauthorized() -> Response { (StatusCode::UNAUTHORIZED, Json(json!({"error":"Administrator authentication required"}))).into_response() } -fn event(state: &AppState, kind: &str, payload: Value) { let message = json!({"version":1,"id":Uuid::new_v4(),"occurredAt":chrono_now(),"roomId":state.config.room_id,"type":kind,"payload":payload}); let _ = state.events.send(message.to_string()); } -fn chrono_now() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } +async fn migrate(url: &str) -> Result<(), String> { + let client = connect(url).await?; + client + .batch_execute(include_str!("../migrations/001_initial.sql")) + .await + .map_err(|e| e.to_string())?; + client + .batch_execute(include_str!("../migrations/002_overlay_settings.sql")) + .await + .map_err(|e| e.to_string()) +} +async fn connect(url: &str) -> Result { + let (client, connection) = tokio_postgres::connect(url, NoTls) + .await + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + if let Err(e) = connection.await { + warn!(error = %e, "postgres connection ended"); + } + }); + Ok(client) +} +fn session(config: &Config) -> String { + let mut mac = HmacSha256::new_from_slice(config.session_secret.as_bytes()).expect("hmac key"); + mac.update(b"admin"); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get("cookie")? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + part.trim() + .split_once('=') + .filter(|(k, _)| *k == name) + .map(|(_, v)| v.to_owned()) + }) +} +fn admin(state: &AppState, headers: &HeaderMap) -> bool { + cookie(headers, "lxc_session").is_some_and(|v| v == session(&state.config)) +} +fn unauthorized() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error":"Administrator authentication required"})), + ) + .into_response() +} +fn envelope(state: &AppState, kind: &str, payload: Value) -> String { + json!({"version":1,"id":Uuid::new_v4(),"occurredAt":chrono_now(),"roomId":state.config.room_id,"type":kind,"payload":payload}).to_string() +} +fn event(state: &AppState, kind: &str, payload: Value) { + let _ = state.events.send(envelope(state, kind, payload)); +} +fn chrono_now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} -async fn health(State(state): State) -> Json { Json(json!({"ok":true,"roomId":state.config.room_id})) } -async fn login(State(state): State, Json(body): Json) -> Response { if body.password != state.config.admin_password { return (StatusCode::UNAUTHORIZED, Json(json!({"error":"Invalid password"}))).into_response(); } let mut response = Json(json!({"ok":true})).into_response(); response.headers_mut().insert("set-cookie", HeaderValue::from_str(&format!("lxc_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200", session(&state.config))).unwrap()); response } -async fn logout() -> Response { let mut r = Json(json!({"ok":true})).into_response(); r.headers_mut().insert("set-cookie", HeaderValue::from_static("lxc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")); r } -async fn status(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let source = state.source.lock().await.clone(); Json(json!({"roomId":state.config.room_id,"source":{"connected":source.connected,"cookieCloud":source.cookie_cloud,"detail":source.detail},"reply":{"enabled":state.reply_enabled.load(Ordering::Relaxed),"available":true,"detail":"CookieCloud-backed reply ready"},"websocketClients":state.events.receiver_count(),"cookieCloudHost":state.config.cookiecloud_host})).into_response() } -async fn viewers(State(state): State, headers: HeaderMap, Query(query): Query) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; let search = format!("%{}%", query.search.unwrap_or_default()); match db.query("SELECT uid,display_name,points FROM viewer_accounts WHERE scope='live' AND room_id=$1 AND (uid ILIKE $2 OR display_name ILIKE $2) ORDER BY updated_at DESC LIMIT 100", &[&state.config.room_id, &search]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"uid":r.get::<_, String>(0),"displayName":r.get::<_, String>(1),"points":r.get::<_, i32>(2)})).collect::>()).into_response(), Err(e) => error_response(e.to_string()) } } -async fn ledger(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; match db.query("SELECT l.id::text,l.uid,a.display_name,l.delta,l.reason,l.created_at::text FROM point_ledger l JOIN viewer_accounts a ON(a.scope=l.scope AND a.room_id=l.room_id AND a.uid=l.uid) WHERE l.scope='live' AND l.room_id=$1 ORDER BY l.created_at DESC LIMIT 100", &[&state.config.room_id]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"id":r.get::<_, String>(0),"uid":r.get::<_, String>(1),"displayName":r.get::<_, String>(2),"delta":r.get::<_, i32>(3),"reason":r.get::<_, String>(4),"createdAt":r.get::<_, String>(5)})).collect::>()).into_response(), Err(e) => error_response(e.to_string()) } } -async fn toggle_reply(State(state): State, headers: HeaderMap, Json(body): Json) -> Response { if !admin(&state, &headers) { return unauthorized(); } state.reply_enabled.store(body.enabled, Ordering::Relaxed); Json(json!({"enabled":body.enabled,"available":true,"detail":"CookieCloud-backed reply ready"})).into_response() } -async fn reconnect(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } spawn_live_listener(state.clone()); Json(json!({"ok":true})).into_response() } -async fn test_event(State(state): State, headers: HeaderMap, Json(body): Json) -> Response { if !admin(&state, &headers) { return unauthorized(); } let message = match body { TestEvent::Enter{uid,name} => Incoming::Enter{viewer:Viewer{uid,name}}, TestEvent::Danmaku{uid,name,text} => Incoming::Danmaku{viewer:Viewer{uid,name},text}, TestEvent::Gift{uid,name,gift_name,battery,quantity} => Incoming::Gift{viewer:Viewer{uid,name},name:gift_name,battery,quantity,event_id:format!("test-{}",Uuid::new_v4())} }; if let Err(e) = process(&state, message, "test").await { return error_response(e); } Json(json!({"ok":true})).into_response() } -async fn ws(State(state): State, headers: HeaderMap, Query(query): Query, upgrade: WebSocketUpgrade) -> Response { let allowed = admin(&state, &headers) || query.token.as_deref() == Some(&state.config.obs_access_token); if !allowed { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } upgrade.on_upgrade(move |socket| ws_loop(socket, state.events.subscribe())) } -async fn ws_loop(mut socket: WebSocket, mut rx: broadcast::Receiver) { while let Ok(text) = rx.recv().await { if socket.send(Message::Text(text.into())).await.is_err() { break; } } } -fn error_response(error: String) -> Response { error!(%error, "request failed"); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"Internal server error"}))).into_response() } +async fn health(State(state): State) -> Json { + Json( + json!({"ok":true,"roomId":state.config.room_id,"giftCatalogSize":state.gift_catalog.len().await,"emoticonCatalogSize":state.emoticon_catalog.len().await}), + ) +} +async fn overlay_page() -> Response { + match tokio::fs::read("/app/web/index.html").await { + Ok(contents) => ([("content-type", "text/html; charset=utf-8")], contents).into_response(), + Err(error) => { + error!(%error, "overlay entry point is missing"); + StatusCode::NOT_FOUND.into_response() + } + } +} +async fn api_not_found() -> Response { + ( + StatusCode::NOT_FOUND, + Json(json!({"error":"API endpoint not found"})), + ) + .into_response() +} +async fn login(State(state): State, Json(body): Json) -> Response { + if body.password != state.config.admin_password { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error":"Invalid password"})), + ) + .into_response(); + } + let mut response = Json(json!({"ok":true})).into_response(); + response.headers_mut().insert( + "set-cookie", + HeaderValue::from_str(&format!( + "lxc_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200", + session(&state.config) + )) + .unwrap(), + ); + response +} +async fn logout() -> Response { + let mut r = Json(json!({"ok":true})).into_response(); + r.headers_mut().insert( + "set-cookie", + HeaderValue::from_static("lxc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"), + ); + r +} +async fn status(State(state): State, headers: HeaderMap) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + let source = state.source.lock().await.clone(); + Json(json!({"roomId":state.config.room_id,"source":{"connected":source.connected,"cookieCloud":source.cookie_cloud,"detail":source.detail},"websocketClients":state.events.receiver_count(),"cookieCloudHost":state.config.cookiecloud_host})).into_response() +} +async fn reconnect(State(state): State, headers: HeaderMap) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + spawn_live_listener(state.clone()); + Json(json!({"ok":true})).into_response() +} +async fn get_overlay_settings(State(state): State, headers: HeaderMap) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + Json(state.overlay_settings.read().await.clone()).into_response() +} +async fn put_overlay_settings( + State(state): State, + headers: HeaderMap, + Json(settings): Json, +) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + let settings = settings.sanitize(); + if let Err(error) = + overlay::save_settings(&state.config.database_url, &state.config.room_id, &settings).await + { + return error_response(error); + } + *state.overlay_settings.write().await = settings.clone(); + event( + &state, + "overlay.settings.updated", + json!({"settings":settings}), + ); + Json(json!({"ok":true,"settings":settings})).into_response() +} +async fn obs_url(State(state): State, headers: HeaderMap) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + Json(json!({"path":format!("/obs?token={}", state.config.obs_access_token)})).into_response() +} +async fn test_event( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !admin(&state, &headers) { + return unauthorized(); + } + let message = match body { + TestEvent::Enter { uid, name } => Incoming::Enter { + viewer: Viewer { uid, name }, + }, + TestEvent::Danmaku { uid, name, text } => Incoming::Danmaku { + viewer: Viewer { uid, name }, + text, + emoticons: Vec::new(), + }, + TestEvent::Gift { + uid, + name, + gift_name, + gift_id, + battery, + quantity, + } => Incoming::Gift { + viewer: Viewer { uid, name }, + name: gift_name, + gift_id, + battery, + quantity, + event_id: format!("test-{}", Uuid::new_v4()), + }, + }; + if let Err(e) = process(&state, message).await { + return error_response(e); + } + Json(json!({"ok":true})).into_response() +} +async fn ws( + State(state): State, + headers: HeaderMap, + Query(query): Query, + upgrade: WebSocketUpgrade, +) -> Response { + let allowed = + admin(&state, &headers) || query.token.as_deref() == Some(&state.config.obs_access_token); + if !allowed { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + upgrade.on_upgrade(move |socket| ws_loop(socket, state)) +} +async fn ws_loop(mut socket: WebSocket, state: AppState) { + let snapshot = state.overlay_settings.read().await.clone(); + if socket + .send(Message::Text( + envelope( + &state, + "overlay.settings.snapshot", + json!({"settings":snapshot}), + ) + .into(), + )) + .await + .is_err() + { + return; + } + let mut rx = state.events.subscribe(); + while let Ok(text) = rx.recv().await { + if socket.send(Message::Text(text.into())).await.is_err() { + break; + } + } +} +fn error_response(error: String) -> Response { + error!(%error, "request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error":"Internal server error"})), + ) + .into_response() +} + +fn spawn_live_listener(state: AppState) { + task::spawn_blocking(move || { + let runtime = tokio::runtime::Handle::current(); + let cookie = match runtime.block_on(cookiecloud_cookie(&state.config)) { + Ok(v) => v, + Err(e) => { + runtime.block_on(set_source(&state, false, false, e)); + return; + } + }; + runtime.block_on(set_source( + &state, + false, + true, + "Connecting with blivedm_rs".into(), + )); + let (sender, mut receiver) = mpsc::channel(256); + let mut client = + match BiliLiveClient::new_auto(Some(&cookie), &state.config.room_id, sender) { + Ok(client) => client, + Err(e) => { + runtime.block_on(set_source(&state, false, true, e)); + return; + } + }; + client.send_auth(); + runtime.block_on(set_source( + &state, + true, + true, + "Connected with authenticated blivedm_rs listener".into(), + )); + loop { + if let Err(e) = client.receive() { + runtime.block_on(set_source(&state, false, true, e)); + } + while let Ok(Some(raw)) = receiver.try_next() { + if let Some(message) = normalize(raw) { + let state = state.clone(); + runtime.block_on(async move { + if let Err(e) = process(&state, message).await { + error!(%e, "live event processing failed"); + } + }); + } + } + } + }); +} +async fn set_source(state: &AppState, connected: bool, cookie_cloud: bool, detail: String) { + *state.source.lock().await = SourceStatus { + connected, + cookie_cloud, + detail, + }; +} +async fn cookiecloud_cookie(config: &Config) -> Result { + let host = config.cookiecloud_host.trim_end_matches('/'); + let response = reqwest::Client::new() + .post(format!("{host}/get/{}", config.cookiecloud_key)) + .form(&[("password", config.cookiecloud_password.as_str())]) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("CookieCloud HTTP {}", response.status())); + } + let value: Value = response.json().await.map_err(|e| e.to_string())?; + let mut cookies = Vec::new(); + if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) { + for (domain, stored) in domains { + if !domain.contains("bilibili.com") { + continue; + } + let entries: Vec<&Value> = if let Some(array) = stored.as_array() { + array.iter().collect() + } else { + stored + .as_object() + .map(|values| values.values().collect()) + .unwrap_or_default() + }; + for c in entries { + if let (Some(name), Some(value)) = ( + c.get("name").and_then(Value::as_str), + c.get("value").and_then(Value::as_str), + ) { + cookies.push(format!("{name}={value}")); + } + } + } + } + if cookies.iter().any(|c| c.starts_with("SESSDATA=")) { + Ok(cookies.join("; ")) + } else { + Err("CookieCloud has no Bilibili SESSDATA cookie".into()) + } +} + +async fn refresh_emoticon_catalog(state: &AppState) -> Result { + let cookie = cookiecloud_cookie(&state.config).await?; + state + .emoticon_catalog + .refresh( + &state.config.room_id, + &cookie, + state.config.emoticon_request_timeout_seconds, + ) + .await +} + +fn spawn_emoticon_catalog_refresh(state: AppState) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs( + state.config.emoticon_refresh_seconds, + )) + .await; + match refresh_emoticon_catalog(&state).await { + Ok(count) => info!(count, "emoticon catalog refreshed"), + Err(error) => { + warn!(%error, "emoticon catalog refresh failed; retaining the last successful cache") + } + } + } + }); +} + +fn json_object(value: &Value) -> Option { + match value { + Value::Object(_) => Some(value.clone()), + Value::String(value) => serde_json::from_str::(value) + .ok() + .filter(Value::is_object), + _ => None, + } +} + +fn value_as_u32(value: Option<&Value>) -> Option { + value.and_then(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| value.as_str()?.parse().ok()) + }) +} + +fn value_is_truthy(value: Option<&Value>) -> bool { + value.is_some_and(|value| { + value.as_bool().unwrap_or(false) + || value.as_i64().is_some_and(|value| value != 0) + || value.as_str().is_some_and(|value| value == "1") + }) +} + +fn emoticon_hint(value: &Value, text: &str, standalone: bool) -> Option { + let value = json_object(value)?; + let unique = value + .get("emoticon_unique") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let url = value + .get("url") + .and_then(Value::as_str) + .and_then(normalize_image_url); + if unique.is_none() && url.is_none() { + return None; + } + Some(DanmakuEmoticonHint { + text: text.to_owned(), + unique, + url, + width: value_as_u32(value.get("width")), + height: value_as_u32(value.get("height")), + is_dynamic: value_is_truthy(value.get("is_dynamic")), + bulge_display: value_is_truthy(value.get("bulge_display")), + standalone, + }) +} + +fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec { + let Some(header) = info.first().and_then(Value::as_array) else { + return Vec::new(); + }; + let mut hints = Vec::new(); + + // Large and room-specific emoticons normally carry their image in info[0][13]. + // Scan the other fields too because Bilibili has shifted this slot before. + if let Some(direct) = header + .get(13) + .and_then(|value| emoticon_hint(value, text, true)) + .or_else(|| { + header.iter().find_map(|value| { + let object = json_object(value)?; + object.get("url")?; + emoticon_hint(&object, text, true) + }) + }) + { + hints.push(direct); + } + + // Inline metadata is nested in a JSON string at info[0][15].extra. + let extra = header.iter().find_map(|value| { + let object = json_object(value)?; + json_object(object.get("extra")?) + }); + if let Some(extra) = extra { + if let Some(emoticons) = extra.get("emots").and_then(json_object) { + if let Some(emoticons) = emoticons.as_object() { + for (token, metadata) in emoticons { + if let Some(hint) = emoticon_hint(metadata, token, false) { + hints.push(hint); + } + } + } + } + if let Some(unique) = extra + .get("emoticon_unique") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + let already_present = hints + .iter() + .any(|hint| hint.unique.as_deref() == Some(unique)); + if !already_present { + hints.push(DanmakuEmoticonHint { + text: text.to_owned(), + unique: Some(unique.to_owned()), + url: None, + width: None, + height: None, + is_dynamic: false, + bulge_display: value_is_truthy(extra.get("bulge_display")), + standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1), + }); + } + } + } + hints +} -fn spawn_live_listener(state: AppState) { task::spawn_blocking(move || { let runtime = tokio::runtime::Handle::current(); let cookie = match runtime.block_on(cookiecloud_cookie(&state.config)) { Ok(v) => v, Err(e) => { runtime.block_on(set_source(&state, false, false, e)); return; } }; runtime.block_on(set_source(&state, false, true, "Connecting with blivedm_rs".into())); let (sender, mut receiver) = mpsc::channel(256); let mut client = match BiliLiveClient::new_auto(Some(&cookie), &state.config.room_id, sender) { Ok(client) => client, Err(e) => { runtime.block_on(set_source(&state, false, true, e)); return; } }; client.send_auth(); runtime.block_on(set_source(&state, true, true, "Connected with authenticated blivedm_rs listener".into())); loop { if let Err(e) = client.receive() { runtime.block_on(set_source(&state, false, true, e)); } while let Ok(Some(raw)) = receiver.try_next() { if let Some(message) = normalize(raw) { let state = state.clone(); runtime.block_on(async move { if let Err(e) = process(&state, message, "live").await { error!(%e, "live event processing failed"); } }); } } } }); } -async fn set_source(state: &AppState, connected: bool, cookie_cloud: bool, detail: String) { *state.source.lock().await = SourceStatus { connected, cookie_cloud, detail }; } -async fn cookiecloud_cookie(config: &Config) -> Result { let host = config.cookiecloud_host.trim_end_matches('/'); let response = reqwest::Client::new().post(format!("{host}/get/{}", config.cookiecloud_key)).form(&[("password", config.cookiecloud_password.as_str())]).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("CookieCloud HTTP {}", response.status())); } let value: Value = response.json().await.map_err(|e| e.to_string())?; let mut cookies = Vec::new(); if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) { for (domain, stored) in domains { if !domain.contains("bilibili.com") { continue; } let entries: Vec<&Value> = if let Some(array) = stored.as_array() { array.iter().collect() } else { stored.as_object().map(|values| values.values().collect()).unwrap_or_default() }; for c in entries { if let (Some(name), Some(value)) = (c.get("name").and_then(Value::as_str), c.get("value").and_then(Value::as_str)) { cookies.push(format!("{name}={value}")); } } } } if cookies.iter().any(|c| c.starts_with("SESSDATA=")) { Ok(cookies.join("; ")) } else { Err("CookieCloud has no Bilibili SESSDATA cookie".into()) } } fn normalize(message: BiliMessage) -> Option { - let raw = match message { BiliMessage::Raw(v) => v, _ => return None }; + let raw = match message { + BiliMessage::Raw(v) => v, + _ => return None, + }; let cmd = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned(); let data = raw.get("data").unwrap_or(&raw); - let viewer = |uid: &Value, name: &Value| Some(Viewer { uid: uid.as_i64().map(|id| id.to_string()).or_else(|| uid.as_str().map(str::to_owned))?, name: name.as_str()?.to_string() }); - let data_viewer = |value: &Value| viewer(value.get("uid")?, value.get("uname").or_else(|| value.pointer("/sender_uinfo/base/name")).or_else(|| value.pointer("/user_info/uname"))?); - let event = |kind: &str, payload: Value| Some(Incoming::Event { kind: kind.into(), payload }); + let viewer = |uid: &Value, name: &Value| { + Some(Viewer { + uid: uid + .as_i64() + .map(|id| id.to_string()) + .or_else(|| uid.as_str().map(str::to_owned))?, + name: name.as_str()?.to_string(), + }) + }; + let data_viewer = |value: &Value| { + viewer( + value.get("uid")?, + value + .get("uname") + .or_else(|| value.pointer("/sender_uinfo/base/name")) + .or_else(|| value.pointer("/user_info/uname"))?, + ) + }; + let event = |kind: &str, payload: Value| { + Some(Incoming::Event { + kind: kind.into(), + payload, + }) + }; match cmd.as_str() { - "DANMU_MSG" => { let info = raw.get("info")?.as_array()?; Some(Incoming::Danmaku { viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?, text: info.get(1)?.as_str()?.to_string() }) } - "SEND_GIFT" => Some(Incoming::Gift { viewer: data_viewer(data)?, name: data.get("giftName").or_else(|| data.get("gift_name"))?.as_str()?.to_string(), battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32, quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32, event_id: data.get("tid").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| format!("{}-{}", data.get("uid").unwrap_or(&Value::Null), data.get("timestamp").unwrap_or(&Value::Null))) }), - "COMBO_SEND" => { let viewer = data_viewer(data)?; event("live.gift.combo", json!({"viewer":viewer,"giftName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("礼物"),"battery":data.get("price").and_then(Value::as_i64).unwrap_or(0),"quantity":data.get("combo_num").or_else(||data.get("total_num")).and_then(Value::as_i64).unwrap_or(1),"comboId":data.get("combo_id").and_then(Value::as_str).unwrap_or("")})) } - "INTERACT_WORD" => Some(Incoming::Enter { viewer: data_viewer(data)? }), - "GUARD_BUY" => { let viewer = data_viewer(data)?; event("live.guard.buy", json!({"viewer":viewer,"guardName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("舰长"),"quantity":data.get("num").and_then(Value::as_i64).unwrap_or(1),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0)})) } - "SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => { let viewer = data_viewer(data)?; event("live.superchat", json!({"viewer":viewer,"message":data.get("message").and_then(Value::as_str).unwrap_or(""),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0),"sourceEventId":data.get("id").map(Value::to_string).unwrap_or_else(||Uuid::new_v4().to_string())})) } - "LIKE_INFO_V3_CLICK" => { let viewer = data_viewer(data)?; event("live.like", json!({"viewer":viewer})) } - "SHARE" => { let viewer = data_viewer(data)?; event("live.share", json!({"viewer":viewer})) } + "DANMU_MSG" => { + let info = raw.get("info")?.as_array()?; + let text = info.get(1)?.as_str()?.to_string(); + Some(Incoming::Danmaku { + viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?, + emoticons: parse_danmaku_emoticons(info, &text), + text, + }) + } + "SEND_GIFT" => Some(Incoming::Gift { + viewer: data_viewer(data)?, + name: data + .get("giftName") + .or_else(|| data.get("gift_name"))? + .as_str()? + .to_string(), + gift_id: data + .get("giftId") + .or_else(|| data.get("gift_id")) + .and_then(Value::as_i64), + battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32, + quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32, + event_id: data + .get("tid") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "{}-{}", + data.get("uid").unwrap_or(&Value::Null), + data.get("timestamp").unwrap_or(&Value::Null) + ) + }), + }), + "COMBO_SEND" => Some(Incoming::Combo { + viewer: data_viewer(data)?, + name: data + .get("gift_name") + .or_else(|| data.get("giftName")) + .and_then(Value::as_str) + .unwrap_or("礼物") + .to_owned(), + gift_id: data + .get("gift_id") + .or_else(|| data.get("giftId")) + .and_then(Value::as_i64), + battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32, + quantity: data + .get("combo_num") + .or_else(|| data.get("total_num")) + .and_then(Value::as_i64) + .unwrap_or(1) as i32, + combo_id: data + .get("combo_id") + .map(Value::to_string) + .unwrap_or_default(), + }), + "INTERACT_WORD" => Some(Incoming::Enter { + viewer: data_viewer(data)?, + }), + "GUARD_BUY" => { + let viewer = data_viewer(data)?; + event( + "live.guard.buy", + json!({"viewer":viewer,"guardName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("舰长"),"quantity":data.get("num").and_then(Value::as_i64).unwrap_or(1),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0)}), + ) + } + "SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => { + let viewer = data_viewer(data)?; + event( + "live.superchat", + json!({"viewer":viewer,"message":data.get("message").and_then(Value::as_str).unwrap_or(""),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0),"sourceEventId":data.get("id").map(Value::to_string).unwrap_or_else(||Uuid::new_v4().to_string())}), + ) + } + "LIKE_INFO_V3_CLICK" => { + let viewer = data_viewer(data)?; + event("live.like", json!({"viewer":viewer})) + } + "SHARE" => { + let viewer = data_viewer(data)?; + event("live.share", json!({"viewer":viewer})) + } _ => event("live.unknown", json!({"cmd":cmd,"raw":raw})), } } -async fn process(state: &AppState, message: Incoming, scope: &str) -> Result<(), String> { if let Incoming::Event { kind, payload } = &message { event(state, kind, payload.clone()); return Ok(()); } let viewer = match &message { Incoming::Enter{viewer}|Incoming::Danmaku{viewer,..}|Incoming::Gift{viewer,..} => viewer.clone(), Incoming::Event{..} => unreachable!() }; let db = connect(&state.config.database_url).await?; db.execute("INSERT INTO viewer_accounts(scope,room_id,uid,display_name) VALUES($1,$2,$3,$4) ON CONFLICT(scope,room_id,uid) DO UPDATE SET display_name=EXCLUDED.display_name,updated_at=now()", &[&scope,&state.config.room_id,&viewer.uid,&viewer.name]).await.map_err(|e|e.to_string())?; match message { Incoming::Enter{..} => event(state,"live.enter",json!({"viewer":viewer})), Incoming::Danmaku{ text,.. } => { event(state,"live.danmaku",json!({"viewer":viewer,"text":text})); command(state, &db, scope, viewer, text).await? }, Incoming::Gift{name,battery,quantity,event_id,..} => { let amount = battery.max(0).saturating_mul(quantity.max(1)); let id = Uuid::new_v4().to_string(); let inserted = db.query_opt("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,source_event_id,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'gift',$6,$7) ON CONFLICT(source_event_id) DO NOTHING RETURNING id", &[&id,&scope,&state.config.room_id,&viewer.uid,&amount,&event_id,&json!({"giftName":name,"battery":battery,"quantity":quantity})]).await.map_err(|e|e.to_string())?; event(state,"live.gift",json!({"viewer":viewer,"giftName":name,"battery":battery,"quantity":quantity,"sourceEventId":event_id})); if inserted.is_some() { let row = db.query_one("UPDATE viewer_accounts SET points=points+$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 RETURNING points", &[&amount,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; let points:i32=row.get(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":amount,"balance":points,"reason":"gift"})); } }, Incoming::Event{..} => unreachable!() } Ok(()) } -async fn command(state: &AppState, db: &Client, scope: &str, viewer: Viewer, text: String) -> Result<(), String> { let normalized = text.split_whitespace().collect::>().join(" "); if normalized == "转盘查询" { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":0,"balance":points,"reason":"gift"})); reply(state, format!("{} 当前转盘点数:{}", viewer.name, points)).await; return Ok(()); } let Some(category) = normalized.strip_prefix("转盘 ").filter(|v| !v.is_empty()) else { if normalized == "转盘" { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"用法:转盘 [类别]"})); reply(state, format!("{}:用法:转盘 [类别]", viewer.name)).await; } return Ok(()); }; let song = random_song(&state.config.songlist_database_url, category).await?; let Some((song_id,title,tags,fallback)) = song else { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"歌单暂时没有可抽取的歌曲"})); return Ok(()); }; let row = db.query_opt("UPDATE viewer_accounts SET points=points-$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 AND points >= $1 RETURNING points", &[&WHEEL_COST,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; if let Some(row) = row { let balance:i32=row.get(0); let id = Uuid::new_v4().to_string(); db.execute("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'wheel',$6)",&[&id,&scope,&state.config.room_id,&viewer.uid,&-WHEEL_COST,&json!({"category":category,"songId":song_id})]).await.map_err(|e|e.to_string())?; event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":-WHEEL_COST,"balance":balance,"reason":"wheel"})); event(state,"wheel.result",json!({"viewer":viewer,"category":category,"fallback":fallback,"song":{"id":song_id,"title":title,"tags":tags},"cost":150,"balance":balance})); reply(state, format!("{} 抽中了《{}》", viewer.name, title)).await; } else { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"wheel.insufficient-balance",json!({"viewer":viewer,"balance":points,"cost":150})); reply(state, format!("{} 点数不足(需要 150,当前 {})", viewer.name, points)).await; } Ok(()) } -async fn reply(state: &AppState, text: String) { if !state.reply_enabled.load(Ordering::Relaxed) { return; } let cookie = match cookiecloud_cookie(&state.config).await { Ok(cookie) => cookie, Err(e) => { warn!(%e, "reply cookie unavailable"); return; } }; let Some(csrf) = cookie.split(';').find_map(|v| v.trim().strip_prefix("bili_jct=").map(str::to_owned)) else { warn!("reply cookie has no bili_jct"); return; }; let response = reqwest::Client::new().post("https://api.live.bilibili.com/msg/send").header("cookie", cookie).header("referer", format!("https://live.bilibili.com/{}", state.config.room_id)).form(&[("roomid", state.config.room_id.as_str()), ("msg", text.as_str()), ("csrf", csrf.as_str()), ("csrf_token", csrf.as_str())]).send().await; if let Err(e) = response { warn!(%e, "Bilibili reply failed"); } } -async fn random_song(url: &str, category: &str) -> Result,bool)>,String> { let db=connect(url).await?; let pattern=format!("%{}%",category.trim().split_whitespace().collect::>().join(" ").to_lowercase()); let query="SELECT s.\"Id\",s.\"Title\",COALESCE(array_agg(t.\"Name\") FILTER (WHERE t.\"Name\" IS NOT NULL), '{}') FROM \"Songs\" s LEFT JOIN \"SongTags\" st ON st.\"SongId\"=s.\"Id\" LEFT JOIN \"Tags\" t ON t.\"Id\"=st.\"TagId\" WHERE s.\"IsHidden\"=false AND EXISTS (SELECT 1 FROM \"SongTags\" mst JOIN \"Tags\" mt ON mt.\"Id\"=mst.\"TagId\" WHERE mst.\"SongId\"=s.\"Id\" AND lower(mt.\"NormalizedName\") LIKE $1) GROUP BY s.\"Id\" ORDER BY random() LIMIT 1"; let row=db.query_opt(query,&[&pattern]).await.map_err(|e|e.to_string())?; let fallback=row.is_none(); let row=match row {Some(r)=>r,None=>match db.query_opt("SELECT s.\"Id\",s.\"Title\",COALESCE(array_agg(t.\"Name\") FILTER (WHERE t.\"Name\" IS NOT NULL), '{}') FROM \"Songs\" s LEFT JOIN \"SongTags\" st ON st.\"SongId\"=s.\"Id\" LEFT JOIN \"Tags\" t ON t.\"Id\"=st.\"TagId\" WHERE s.\"IsHidden\"=false GROUP BY s.\"Id\" ORDER BY random() LIMIT 1",&[]).await.map_err(|e|e.to_string())?{Some(r)=>r,None=>return Ok(None)}}; Ok(Some((row.get(0),row.get(1),row.get(2),fallback))) } +async fn gift_payload( + state: &AppState, + name: String, + gift_id: Option, + battery: i32, + quantity: i32, +) -> Value { + let metadata = state.gift_catalog.get(gift_id, &name).await; + let unit_price = metadata + .as_ref() + .map(|gift| gift.unit_price) + .unwrap_or_else(|| i64::from(battery.max(0))); + let quantity = quantity.max(1); + json!({ + "id": metadata.as_ref().and_then(|gift| gift.id).or(gift_id), + "name": metadata.as_ref().map(|gift| gift.name.clone()).unwrap_or(name), + "coinType": metadata.as_ref().map(|gift| gift.coin_type.clone()).unwrap_or_else(|| "gold".into()), + "unitPrice": unit_price, "totalPrice": unit_price.saturating_mul(i64::from(quantity)), + "priceCny": (unit_price.saturating_mul(i64::from(quantity)) as f64) / 1000.0, + "imageUrl": metadata.as_ref().and_then(|gift| gift.image_url.clone()), + "animationUrl": metadata.as_ref().and_then(|gift| gift.animation_url.clone()), + "effectType": metadata.as_ref().and_then(|gift| gift.effect_type.clone()), + "stayTime": metadata.as_ref().and_then(|gift| gift.stay_time), + }) +} + +fn emoticon_segment(emoticon: &ResolvedDanmakuEmoticon) -> DanmakuSegment { + DanmakuSegment::Emoticon { + text: emoticon.text.clone(), + unique: emoticon.unique.clone(), + url: emoticon.url.clone(), + width: emoticon.width, + height: emoticon.height, + is_dynamic: emoticon.is_dynamic, + standalone: emoticon.standalone, + } +} + +async fn danmaku_segments( + catalog: &EmoticonCatalog, + text: &str, + hints: Vec, +) -> Vec { + let mut resolved: Vec = Vec::new(); + for hint in hints { + let fallback: Option = catalog.get(hint.unique.as_deref(), &hint.text).await; + let Some(url) = hint + .url + .clone() + .or_else(|| fallback.as_ref().map(|emoticon| emoticon.url.clone())) + else { + continue; + }; + let standalone = hint.standalone + || (hint.bulge_display && hint.text == text) + || fallback + .as_ref() + .is_some_and(|emoticon| emoticon.bulge_display && hint.text == text); + let emoticon = ResolvedDanmakuEmoticon { + text: if hint.text.is_empty() { + fallback + .as_ref() + .map(|emoticon| emoticon.emoji.clone()) + .unwrap_or_default() + } else { + hint.text + }, + unique: hint.unique.or_else(|| { + fallback + .as_ref() + .and_then(|emoticon| emoticon.unique.clone()) + }), + url, + width: hint + .width + .or_else(|| fallback.as_ref().and_then(|emoticon| emoticon.width)), + height: hint + .height + .or_else(|| fallback.as_ref().and_then(|emoticon| emoticon.height)), + is_dynamic: hint.is_dynamic + || fallback + .as_ref() + .is_some_and(|emoticon| emoticon.is_dynamic), + standalone, + }; + if !resolved.iter().any(|current| { + current.text == emoticon.text && current.standalone == emoticon.standalone + }) { + resolved.push(emoticon); + } + } + + if let Some(emoticon) = resolved.iter().find(|emoticon| emoticon.standalone) { + return vec![emoticon_segment(emoticon)]; + } + + let mut segments = Vec::new(); + let mut cursor = 0; + while cursor < text.len() { + let remaining = &text[cursor..]; + let mut next: Option<(usize, usize)> = None; + for (index, emoticon) in resolved.iter().enumerate() { + if emoticon.text.is_empty() { + continue; + } + let Some(position) = remaining.find(&emoticon.text) else { + continue; + }; + let replace = next.is_none_or(|(current_index, current_position)| { + position < current_position + || (position == current_position + && emoticon.text.len() > resolved[current_index].text.len()) + }); + if replace { + next = Some((index, position)); + } + } + let Some((index, position)) = next else { + segments.push(DanmakuSegment::Text { + text: remaining.to_owned(), + }); + cursor = text.len(); + continue; + }; + let absolute = cursor + position; + if absolute > cursor { + segments.push(DanmakuSegment::Text { + text: text[cursor..absolute].to_owned(), + }); + } + segments.push(emoticon_segment(&resolved[index])); + cursor = absolute + resolved[index].text.len(); + } + if segments.is_empty() { + segments.push(DanmakuSegment::Text { + text: text.to_owned(), + }); + } + segments +} + +async fn process(state: &AppState, message: Incoming) -> Result<(), String> { + if let Incoming::Event { kind, payload } = &message { + event(state, kind, payload.clone()); + return Ok(()); + } + let viewer = match &message { + Incoming::Enter { viewer } + | Incoming::Danmaku { viewer, .. } + | Incoming::Gift { viewer, .. } + | Incoming::Combo { viewer, .. } => viewer.clone(), + Incoming::Event { .. } => unreachable!(), + }; + match message { + Incoming::Enter { .. } => event(state, "live.enter", json!({"viewer":viewer})), + Incoming::Danmaku { + text, emoticons, .. + } => { + let segments = danmaku_segments(&state.emoticon_catalog, &text, emoticons).await; + event( + state, + "live.danmaku", + json!({"viewer":viewer,"text":text,"segments":segments}), + ); + } + Incoming::Combo { + name, + gift_id, + battery, + quantity, + combo_id, + .. + } => { + let gift = gift_payload(state, name, gift_id, battery, quantity).await; + event( + state, + "live.gift.combo", + json!({"viewer":viewer,"gift":gift,"quantity":quantity,"comboId":combo_id}), + ); + } + Incoming::Gift { + name, + gift_id, + battery, + quantity, + event_id, + .. + } => { + let gift = gift_payload(state, name, gift_id, battery, quantity).await; + event( + state, + "live.gift", + json!({"viewer":viewer,"gift":gift,"quantity":quantity,"sourceEventId":event_id}), + ); + } + Incoming::Event { .. } => unreachable!(), + } + Ok(()) +} #[cfg(test)] mod tests { @@ -142,18 +1287,107 @@ mod tests { #[test] fn normalizes_raw_danmaku_with_uid() { - let message = normalize(BiliMessage::Raw(json!({"cmd":"DANMU_MSG:4:0:2:2:2:0","info":[[],"转盘 查询",[12345,"观众"]]}))).expect("danmaku should normalize"); + let message = normalize(BiliMessage::Raw( + json!({"cmd":"DANMU_MSG:4:0:2:2:2:0","info":[[],"晚上好",[12345,"观众"]]}), + )) + .expect("danmaku should normalize"); match message { - Incoming::Danmaku { viewer, text } => { assert_eq!(viewer.uid, "12345"); assert_eq!(viewer.name, "观众"); assert_eq!(text, "转盘 查询"); } + Incoming::Danmaku { + viewer, + text, + emoticons, + } => { + assert_eq!(viewer.uid, "12345"); + assert_eq!(viewer.name, "观众"); + assert_eq!(text, "晚上好"); + assert!(emoticons.is_empty()); + } _ => panic!("expected danmaku"), } } + #[tokio::test] + async fn normalizes_and_segments_repeated_inline_emoticons() { + let mut header = vec![Value::Null; 16]; + header[15] = json!({"extra":json!({ + "emots":{"[热]":{"emoticon_unique":"emoji_278","url":"http://i0.hdslb.com/bfs/live/hot.png","width":20,"height":20,"is_dynamic":1}} + }).to_string()}); + let message = normalize(BiliMessage::Raw(json!({ + "cmd":"DANMU_MSG", + "info":[header,"你好[热]再见[热]",[12345,"观众"]] + }))) + .expect("inline emoticons should normalize"); + let Incoming::Danmaku { + text, emoticons, .. + } = message + else { + panic!("expected danmaku"); + }; + assert_eq!(emoticons.len(), 1); + assert_eq!( + emoticons[0].url.as_deref(), + Some("https://i0.hdslb.com/bfs/live/hot.png") + ); + let segments = danmaku_segments(&EmoticonCatalog::default(), &text, emoticons).await; + let segments = json!(segments); + assert_eq!(segments.as_array().map(Vec::len), Some(4)); + assert_eq!(segments[0], json!({"type":"text","text":"你好"})); + assert_eq!(segments[1]["type"], "emoticon"); + assert_eq!(segments[1]["text"], "[热]"); + assert_eq!(segments[3]["type"], "emoticon"); + } + + #[tokio::test] + async fn normalizes_standalone_emoticon_from_json_string() { + let mut header = vec![Value::Null; 16]; + header[13] = Value::String( + json!({ + "emoticon_unique":"room_1_2", + "url":"//i0.hdslb.com/bfs/live/room.png", + "width":162, + "height":162, + "is_dynamic":1, + "bulge_display":1 + }) + .to_string(), + ); + let message = normalize(BiliMessage::Raw(json!({ + "cmd":"DANMU_MSG", + "info":[header,"加油",[12345,"观众"]] + }))) + .expect("standalone emoticon should normalize"); + let Incoming::Danmaku { + text, emoticons, .. + } = message + else { + panic!("expected danmaku"); + }; + let segments = json!(danmaku_segments(&EmoticonCatalog::default(), &text, emoticons).await); + assert_eq!(segments.as_array().map(Vec::len), Some(1)); + assert_eq!(segments[0]["type"], "emoticon"); + assert_eq!(segments[0]["standalone"], true); + assert_eq!(segments[0]["url"], "https://i0.hdslb.com/bfs/live/room.png"); + } + #[test] - fn normalizes_raw_gift_for_deduplicated_accounting() { + fn normalizes_raw_gift_with_display_metadata() { let message = normalize(BiliMessage::Raw(json!({"cmd":"SEND_GIFT","data":{"uid":42,"uname":"送礼者","giftName":"小花花","price":100,"num":3,"tid":"gift-event-1"}}))).expect("gift should normalize"); match message { - Incoming::Gift { viewer, name, battery, quantity, event_id } => { assert_eq!(viewer.uid, "42"); assert_eq!(name, "小花花"); assert_eq!(battery, 100); assert_eq!(quantity, 3); assert_eq!(event_id, "gift-event-1"); } + Incoming::Gift { + viewer, + name, + gift_id, + battery, + quantity, + event_id, + } => { + assert_eq!(viewer.uid, "42"); + assert_eq!(name, "小花花"); + assert_eq!(gift_id, None); + assert_eq!(battery, 100); + assert_eq!(quantity, 3); + assert_eq!(event_id, "gift-event-1"); + } _ => panic!("expected gift"), } } diff --git a/apps/server-rust/src/overlay.rs b/apps/server-rust/src/overlay.rs new file mode 100644 index 0000000..b54c8c2 --- /dev/null +++ b/apps/server-rust/src/overlay.rs @@ -0,0 +1,545 @@ +use std::{collections::HashMap, sync::Arc}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +use tokio_postgres::NoTls; +use tracing::warn; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OverlaySettings { + pub title: String, + #[serde(default = "default_font_scale")] + pub font_scale: u16, + pub show_danmaku: bool, + pub show_enter: bool, + pub show_gift: bool, + pub show_superchat: bool, + pub show_guard: bool, + pub show_like: bool, + pub show_share: bool, + pub max_visible: u8, + pub collapse_after_seconds: u16, + #[serde(default = "default_unfold_duration_ms")] + pub unfold_duration_ms: u16, + pub motion_intensity: u8, + #[serde(default = "default_particle_count")] + pub particle_count: u8, + #[serde(default = "default_particle_speed")] + pub particle_speed: u16, + pub low_performance_mode: bool, + /// Bilibili gift-panel prices are in thousandths of a yuan. + pub high_value_threshold: i64, + pub featured_value_threshold: i64, +} + +impl Default for OverlaySettings { + fn default() -> Self { + Self { + title: "洛星瓷专用弹幕猪!".into(), + font_scale: default_font_scale(), + show_danmaku: true, + show_enter: true, + show_gift: true, + show_superchat: true, + show_guard: true, + show_like: false, + show_share: false, + max_visible: 5, + collapse_after_seconds: 12, + unfold_duration_ms: default_unfold_duration_ms(), + motion_intensity: 70, + particle_count: default_particle_count(), + particle_speed: default_particle_speed(), + low_performance_mode: false, + high_value_threshold: 10_000, + featured_value_threshold: 100_000, + } + } +} + +impl OverlaySettings { + pub fn sanitize(mut self) -> Self { + self.title = self.title.trim().chars().take(48).collect(); + if self.title.is_empty() { + self.title = Self::default().title; + } + self.max_visible = self.max_visible.clamp(1, 12); + self.font_scale = self.font_scale.clamp(50, 300); + self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120); + self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000); + self.motion_intensity = self.motion_intensity.min(100); + self.particle_count = self.particle_count.min(12); + self.particle_speed = self.particle_speed.clamp(25, 300); + self.high_value_threshold = self.high_value_threshold.max(0); + self.featured_value_threshold = + self.featured_value_threshold.max(self.high_value_threshold); + self + } +} + +fn default_font_scale() -> u16 { + 140 +} + +fn default_unfold_duration_ms() -> u16 { + 1_000 +} + +fn default_particle_count() -> u8 { + 8 +} + +fn default_particle_speed() -> u16 { + 100 +} + +pub async fn load_settings( + database_url: &str, + room_id: &str, + defaults: OverlaySettings, +) -> Result { + let (client, connection) = tokio_postgres::connect(database_url, NoTls) + .await + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let _ = connection.await; + }); + let row = client + .query_opt( + "SELECT settings FROM overlay_settings WHERE room_id=$1", + &[&room_id], + ) + .await + .map_err(|e| e.to_string())?; + Ok(row + .and_then(|row| serde_json::from_value::(row.get::<_, Value>(0)).ok()) + .unwrap_or(defaults) + .sanitize()) +} + +pub async fn save_settings( + database_url: &str, + room_id: &str, + settings: &OverlaySettings, +) -> Result<(), String> { + let (client, connection) = tokio_postgres::connect(database_url, NoTls) + .await + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let _ = connection.await; + }); + client.execute("INSERT INTO overlay_settings(room_id,settings,updated_at) VALUES($1,$2,now()) ON CONFLICT(room_id) DO UPDATE SET settings=EXCLUDED.settings,updated_at=now()", &[&room_id, &json!(settings)]).await.map_err(|e| e.to_string())?; + Ok(()) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GiftMeta { + pub id: Option, + pub name: String, + pub coin_type: String, + pub unit_price: i64, + pub image_url: Option, + pub animation_url: Option, + pub effect_type: Option, + pub stay_time: Option, +} + +#[derive(Clone, Default)] +pub struct GiftCatalog { + by_id: Arc>>, + by_name: Arc>>, +} + +#[derive(Clone, Debug)] +pub struct EmoticonMeta { + pub emoji: String, + pub unique: Option, + pub url: String, + pub width: Option, + pub height: Option, + pub is_dynamic: bool, + pub bulge_display: bool, +} + +#[derive(Clone, Default)] +pub struct EmoticonCatalog { + by_unique: Arc>>, + by_emoji: Arc>>, +} + +impl EmoticonCatalog { + pub async fn len(&self) -> usize { + self.by_unique.read().await.len() + } + + pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option { + if let Some(unique) = unique.filter(|value| !value.is_empty()) { + if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() { + return Some(emoticon); + } + } + self.by_emoji.read().await.get(emoji).cloned() + } + + pub async fn refresh( + &self, + room_id: &str, + cookie: &str, + timeout_seconds: u64, + ) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_seconds)) + .build() + .map_err(|error| error.to_string())?; + let response = client + .get("https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons") + .query(&[("platform", "pc"), ("room_id", room_id)]) + .header(reqwest::header::COOKIE, cookie) + .header(reqwest::header::REFERER, "https://live.bilibili.com/") + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 lxc-streamutils/1.0", + ) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("emoticon API HTTP {}", response.status())); + } + let payload: Value = response.json().await.map_err(|error| error.to_string())?; + if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 { + return Err(format!( + "emoticon API error: {}", + payload + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + } + let (unique, emoji, count) = parse_emoticon_catalog(&payload)?; + *self.by_unique.write().await = unique; + *self.by_emoji.write().await = emoji; + Ok(count) + } +} + +impl GiftCatalog { + pub async fn len(&self) -> usize { + self.by_id.read().await.len() + } + + pub async fn get(&self, id: Option, name: &str) -> Option { + if let Some(id) = id { + if let Some(gift) = self.by_id.read().await.get(&id).cloned() { + return Some(gift); + } + } + self.by_name + .read() + .await + .get(&normalize_name(name)) + .cloned() + } + + pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_seconds)) + .build() + .map_err(|e| e.to_string())?; + let response = client + .get("https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList") + // Despite the endpoint name, the current API only includes + // gift_config for platform=pc. platform=web may return code=0 + // with an empty room_gift_list and no catalog. + .query(&[("platform", "pc"), ("room_id", room_id)]) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("gift panel HTTP {}", response.status())); + } + let payload: Value = response.json().await.map_err(|e| e.to_string())?; + if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 { + return Err(format!( + "gift panel API error: {}", + payload + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + } + let (ids, names, count) = parse_catalog(&payload)?; + *self.by_id.write().await = ids; + *self.by_name.write().await = names; + Ok(count) + } + + pub fn spawn_refresh(self, room_id: String, interval_seconds: u64, timeout_seconds: u64) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval_seconds)).await; + if let Err(error) = self.refresh(&room_id, timeout_seconds).await { + warn!(%error, "gift catalog refresh failed; retaining the last successful cache"); + } + } + }); + } +} + +fn parse_catalog( + payload: &Value, +) -> Result<(HashMap, HashMap, usize), String> { + let list = payload + .pointer("/data/gift_config/base_config/list") + .and_then(Value::as_array) + .ok_or("gift panel response has no gift list")?; + let mut ids = HashMap::new(); + let mut names = HashMap::new(); + for item in list { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + let id = item.get("id").and_then(Value::as_i64); + let gift = GiftMeta { + id, + name: name.to_owned(), + coin_type: item + .get("coin_type") + .and_then(Value::as_str) + .unwrap_or("gold") + .to_owned(), + unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0), + image_url: string_field(item, "img_basic"), + animation_url: string_field(item, "gif"), + effect_type: item.get("effect").and_then(|value| match value { + Value::String(value) if !value.is_empty() => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }), + stay_time: item.get("stay_time").and_then(Value::as_i64), + }; + if let Some(id) = id { + ids.insert(id, gift.clone()); + } + names.insert(normalize_name(name), gift); + } + if ids.is_empty() && names.is_empty() { + return Err("gift panel returned an empty catalog".into()); + } + Ok((ids, names, list.len())) +} + +fn string_field(value: &Value, name: &str) -> Option { + value + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_emoticon_catalog( + payload: &Value, +) -> Result< + ( + HashMap, + HashMap, + usize, + ), + String, +> { + let packages = payload + .pointer("/data/data") + .and_then(Value::as_array) + .ok_or("emoticon response has no package list")?; + let mut by_unique = HashMap::new(); + let mut by_emoji = HashMap::new(); + let mut count = 0; + for item in packages + .iter() + .filter_map(|package| package.get("emoticons").and_then(Value::as_array)) + .flatten() + { + let emoji = item + .get("emoji") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let unique = item + .get("emoticon_unique") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let Some(url) = item + .get("url") + .and_then(Value::as_str) + .and_then(normalize_image_url) + else { + continue; + }; + let emoticon = EmoticonMeta { + emoji: emoji.clone(), + unique: unique.clone(), + url, + width: number_as_u32(item.get("width")), + height: number_as_u32(item.get("height")), + is_dynamic: truthy(item.get("is_dynamic")), + bulge_display: truthy(item.get("bulge_display")), + }; + if let Some(unique) = unique { + by_unique.insert(unique, emoticon.clone()); + } + if !emoji.is_empty() { + by_emoji.insert(emoji, emoticon); + } + count += 1; + } + if count == 0 { + return Err("emoticon API returned an empty catalog".into()); + } + Ok((by_unique, by_emoji, count)) +} + +pub fn normalize_image_url(url: &str) -> Option { + let url = url.trim(); + if let Some(rest) = url.strip_prefix("http://") { + Some(format!("https://{rest}")) + } else if url.starts_with("https://") { + Some(url.to_owned()) + } else if url.starts_with("//") { + Some(format!("https:{url}")) + } else { + None + } +} + +fn number_as_u32(value: Option<&Value>) -> Option { + value.and_then(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| value.as_str()?.parse().ok()) + }) +} + +fn truthy(value: Option<&Value>) -> bool { + value.is_some_and(|value| { + value.as_bool().unwrap_or(false) + || value.as_i64().is_some_and(|value| value != 0) + || value.as_str().is_some_and(|value| value == "1") + }) +} +fn normalize_name(name: &str) -> String { + name.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_current_gift_panel_fields_and_indexes_by_id_and_name() { + let payload = json!({"data":{"gift_config":{"base_config":{"list":[{ + "id":42,"name":"青玉灯","price":30000,"coin_type":"gold","img_basic":"https://example.test/gift.png", + "gif":"https://example.test/gift.gif","effect":2,"stay_time":3 + }]}}}}); + let (ids, names, count) = parse_catalog(&payload).expect("catalog"); + assert_eq!(count, 1); + assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000); + assert_eq!( + names + .get("青玉灯") + .expect("name index") + .effect_type + .as_deref(), + Some("2") + ); + } + + #[test] + fn rejects_an_empty_catalog_without_replacing_the_cache() { + let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}}); + assert!(parse_catalog(&payload).is_err()); + } + + #[tokio::test] + async fn parses_emoticon_packages_and_normalizes_image_urls() { + let payload = json!({"code":0,"data":{"data":[{"emoticons":[{ + "emoji":"[热]", + "emoticon_unique":"emoji_278", + "url":"http://i0.hdslb.com/bfs/live/hot.png", + "width":20, + "height":20, + "is_dynamic":1, + "bulge_display":0 + }]}]}}); + let (by_unique, by_emoji, count) = + parse_emoticon_catalog(&payload).expect("emoticon catalog"); + assert_eq!(count, 1); + assert_eq!( + by_unique.get("emoji_278").expect("unique index").url, + "https://i0.hdslb.com/bfs/live/hot.png" + ); + assert!(by_emoji.get("[热]").expect("emoji index").is_dynamic); + + let catalog = EmoticonCatalog { + by_unique: Arc::new(RwLock::new(by_unique)), + by_emoji: Arc::new(RwLock::new(by_emoji)), + }; + assert!(catalog.get(Some("emoji_278"), "missing").await.is_some()); + } + + #[test] + fn settings_are_bounded_before_persistence() { + let settings = OverlaySettings { + title: " ".into(), + font_scale: 999, + max_visible: 99, + collapse_after_seconds: 1, + unfold_duration_ms: 9_000, + motion_intensity: 200, + particle_count: 255, + particle_speed: 0, + high_value_threshold: -1, + featured_value_threshold: -2, + ..OverlaySettings::default() + } + .sanitize(); + assert_eq!(settings.title, "洛星瓷专用弹幕猪!"); + assert_eq!(settings.font_scale, 300); + assert_eq!(settings.max_visible, 12); + assert_eq!(settings.collapse_after_seconds, 2); + assert_eq!(settings.unfold_duration_ms, 5_000); + assert_eq!(settings.motion_intensity, 100); + assert_eq!(settings.particle_count, 12); + assert_eq!(settings.particle_speed, 25); + assert_eq!(settings.featured_value_threshold, 0); + + let settings = OverlaySettings { + particle_speed: u16::MAX, + ..OverlaySettings::default() + } + .sanitize(); + assert_eq!(settings.particle_speed, 300); + } + + #[test] + fn old_saved_settings_receive_new_field_defaults() { + let mut value = json!(OverlaySettings::default()); + let object = value.as_object_mut().expect("settings object"); + object.remove("fontScale"); + object.remove("unfoldDurationMs"); + object.remove("particleCount"); + object.remove("particleSpeed"); + let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings"); + assert_eq!(settings.font_scale, 140); + assert_eq!(settings.unfold_duration_ms, 1_000); + assert_eq!(settings.particle_count, 8); + assert_eq!(settings.particle_speed, 100); + } +} diff --git a/config.toml.example b/config.toml.example index 9044f9e..d24dad2 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,4 +1,4 @@ -# 洛星瓷直播转盘服务配置。 +# 洛星瓷直播弹幕姬配置。 # # 使用方式:复制为 config.toml 后填写所有 replace-with-* 项;该文件包含 # CookieCloud、数据库和访问令牌等敏感信息,请不要提交或公开分享。 @@ -14,13 +14,9 @@ room_id = "000000" [server] port = 9719 -# 转盘系统自己的读写数据库。此账号必须可创建和修改业务表。 +# 弹幕姬设置数据库。此账号必须可创建和修改 overlay_settings 表。 [database] -url = "postgresql://wheel:replace-with-wheel-password@127.0.0.1:5432/wheel?sslmode=disable" - -# 洛星瓷歌单数据库,只用于读取歌曲和标签,建议使用只读账号。 -[songlist] -database_url = "postgresql://lxc_songlist:replace-with-songlist-password@127.0.0.1:5432/lxc_songlist?sslmode=disable" +url = "postgresql://streamutils:replace-with-database-password@127.0.0.1:5432/streamutils?sslmode=disable" # 外部部署的 CookieCloud 实例。key 是同步 UUID;password 是同步密码。 [cookiecloud] @@ -38,10 +34,47 @@ session_secret = "replace-with-at-least-32-random-characters" [obs] access_token = "replace-with-a-long-random-obs-token" -# 启用后,服务会用 CookieCloud 中的 bili_jct 和 Cookie 向直播间发送回复。 -# 回复失败不会影响记账、抽歌或 OBS 推送。 -[reply] -enabled = false +# Bilibili 礼物目录缓存。启动时会立即加载,之后按此间隔刷新; +# 请求失败会继续使用上一次成功结果。刷新间隔最小 60 秒。 +[gifts] +refresh_interval_seconds = 600 +request_timeout_seconds = 10 + +# Bilibili 直播间表情目录缓存。接口使用 CookieCloud 中的 SESSDATA; +# 实时消息自带的表情图片始终优先,目录仅用于补齐缺失的图片地址。 +[emoticons] +refresh_interval_seconds = 600 +request_timeout_seconds = 10 + +# OBS 弹幕姬的初始设置。仅当该直播间尚未通过 /control 保存过设置时使用; +# 一旦控制台保存,PostgreSQL 中的设置优先。价格阈值单位为千分之一元, +# 因此 10000 = 10 元、100000 = 100 元。 +[overlay] +title = "洛星瓷专用弹幕猪!" +# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。 +font_scale = 140 +max_visible = 5 +collapse_after_seconds = 12 +# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。 +unfold_duration_ms = 1000 +motion_intensity = 70 +# 每张消息卡片的闪光粒子数量;0 可关闭粒子,允许范围 0-12。 +particle_count = 8 +# 粒子动画速度百分比;100 为标准速度,允许范围 25-300。 +particle_speed = 100 +low_performance_mode = false +high_value_threshold = 10000 +featured_value_threshold = 100000 + +# 初始显示的事件类别,同样可在 /control 中随时修改并实时同步到 OBS。 +[overlay.events] +danmaku = true +enter = true +gift = true +superchat = true +guard = true +like = false +share = false # 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。 # 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。 diff --git a/deploy/nginx/danmaku.luoxingci.com.conf b/deploy/nginx/danmaku.luoxingci.com.conf new file mode 100644 index 0000000..53e0c4d --- /dev/null +++ b/deploy/nginx/danmaku.luoxingci.com.conf @@ -0,0 +1,27 @@ +server { + listen 80; + listen [::]:80; + + server_name danmaku.luoxingci.com; + + access_log /var/log/nginx/danmaku.luoxingci.com.access.log; + error_log /var/log/nginx/danmaku.luoxingci.com.error.log; + + location / { + proxy_pass http://127.0.0.1:9719; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; + proxy_buffering off; + } +}