diff --git a/VERCEL_DEPLOYMENT.md b/VERCEL_DEPLOYMENT.md new file mode 100644 index 0000000..293067d --- /dev/null +++ b/VERCEL_DEPLOYMENT.md @@ -0,0 +1,88 @@ +# Vercel 部署指南 + +本项目已支持使用 **Vercel Postgres** 作为数据库后端,可在 Vercel 平台上稳定部署。 + +## 环境变量配置 + +在 Vercel 项目设置中添加以下环境变量: + +### 必需的环境变量 + +| 变量名 | 说明 | 示例值 | +|--------|------|--------| +| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `postgres` | +| `POSTGRES_URL` | Vercel Postgres 连接字符串 | `postgres://...` | +| `USERNAME` | 管理员用户名 | `admin` | +| `PASSWORD` | 管理员密码 | `your_password` | + +### Vercel Postgres 连接字符串 + +Vercel Postgres 会自动提供以下环境变量: +- `POSTGRES_URL` - 完整连接字符串 +- `POSTGRES_PRISMA_URL` - Prisma 兼容连接字符串 +- `POSTGRES_URL_NON_POOLING` - 无连接池连接字符串 +- `POSTGRES_USER` - 数据库用户名 +- `POSTGRES_HOST` - 数据库主机 +- `POSTGRES_PASSWORD` - 数据库密码 +- `POSTGRES_DATABASE` - 数据库名称 + +通常只需要使用 `POSTGRES_URL` 即可。 + +## 部署步骤 + +### 1. 创建 Vercel Postgres 数据库 + +```bash +# 安装 Vercel CLI +npm i -g vercel + +# 登录 Vercel +vercel login + +# 创建 Postgres 数据库 +vercel postgres create + +# 选择数据库并连接到项目 +vercel postgres connect +``` + +### 2. 初始化数据库表结构 + +```bash +# 运行数据库初始化脚本 +pnpm init:postgres +``` + +### 3. 部署到 Vercel + +```bash +# 部署项目 +vercel --prod +``` + +## 功能限制 + +由于 Vercel 的 serverless 环境限制,以下功能不可用: + +- **观影室** (Watch Room) - 需要 WebSocket 支持,Vercel serverless 不支持长时间运行的连接 + +## 存储类型对比 + +| 存储类型 | 部署平台 | 数据持久化 | 说明 | +|---------|---------|-----------|------| +| `localstorage` | 任意 | ❌ 浏览器本地 | 仅用于测试 | +| `d1` | Cloudflare | ✅ | Cloudflare D1 数据库 | +| `postgres` | Vercel | ✅ | Vercel Postgres 数据库 | +| `redis` | 自建服务器 | ✅ | Redis 数据库 | +| `upstash` | Vercel | ✅ | Upstash Redis | +| `kvrocks` | 自建服务器 | ✅ | Kvrocks 数据库 | + +## 数据迁移 + +如果需要从其他存储类型迁移数据到 Vercel Postgres,请使用管理后台的数据迁移功能。 + +## 注意事项 + +1. **数据库配额**:Vercel Postgres 免费版有 256 MB 存储限制 +2. **连接池**:Vercel Postgres 自动管理连接池,无需手动配置 +3. **冷启动**:首次请求可能需要几秒钟冷启动时间 diff --git a/migrations/postgres/001_initial_schema.sql b/migrations/postgres/001_initial_schema.sql new file mode 100644 index 0000000..9d5ac1a --- /dev/null +++ b/migrations/postgres/001_initial_schema.sql @@ -0,0 +1,170 @@ +-- ============================================ +-- MoonTV Plus - Vercel Postgres 数据库结构 +-- 版本: 1.0.0 +-- 创建时间: 2026-02-07 +-- ============================================ + +-- 1. 用户表 +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'user')), + banned INTEGER DEFAULT 0, + tags TEXT, -- JSON array: ["vip", "premium"] + oidc_sub TEXT UNIQUE, + enabled_apis TEXT, -- JSON array: ["api1", "api2"] + created_at BIGINT NOT NULL, + playrecord_migrated INTEGER DEFAULT 0, + favorite_migrated INTEGER DEFAULT 0, + skip_migrated INTEGER DEFAULT 0, + last_movie_request_time BIGINT, + email TEXT, + email_notifications INTEGER DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); + +-- 2. 播放记录表 +CREATE TABLE IF NOT EXISTS play_records ( + username TEXT NOT NULL, + key TEXT NOT NULL, -- format: "source+id" (e.g., "tmdb+12345") + title TEXT NOT NULL, + source_name TEXT NOT NULL, + cover TEXT, + year TEXT, + episode_index INTEGER NOT NULL, + total_episodes INTEGER NOT NULL, + play_time BIGINT NOT NULL, -- 播放进度(秒) + total_time BIGINT NOT NULL, -- 总时长(秒) + save_time BIGINT NOT NULL, -- 保存时间戳 + search_title TEXT, + PRIMARY KEY (username, key), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_play_records_save_time ON play_records(username, save_time DESC); +CREATE INDEX IF NOT EXISTS idx_play_records_source ON play_records(username, source_name); + +-- 3. 收藏表 +CREATE TABLE IF NOT EXISTS favorites ( + username TEXT NOT NULL, + key TEXT NOT NULL, -- format: "source+id" + source_name TEXT NOT NULL, + total_episodes INTEGER NOT NULL, + title TEXT NOT NULL, + year TEXT, + cover TEXT, + save_time BIGINT NOT NULL, + search_title TEXT, + origin TEXT CHECK(origin IN ('vod', 'live')), + is_completed INTEGER DEFAULT 0, + vod_remarks TEXT, + PRIMARY KEY (username, key), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_favorites_save_time ON favorites(username, save_time DESC); +CREATE INDEX IF NOT EXISTS idx_favorites_source ON favorites(username, source_name); + +-- 4. 搜索历史表 +CREATE TABLE IF NOT EXISTS search_history ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + keyword TEXT NOT NULL, + timestamp BIGINT NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE, + UNIQUE(username, keyword) +); + +CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(username, timestamp DESC); + +-- 5. 跳过配置表(片头片尾) +CREATE TABLE IF NOT EXISTS skip_configs ( + username TEXT NOT NULL, + key TEXT NOT NULL, -- format: "source+id" + enable INTEGER NOT NULL DEFAULT 1, + intro_time INTEGER NOT NULL DEFAULT 0, -- 片头时长(秒) + outro_time INTEGER NOT NULL DEFAULT 0, -- 片尾时长(秒) + PRIMARY KEY (username, key), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +-- 6. 弹幕过滤配置表 +CREATE TABLE IF NOT EXISTS danmaku_filter_configs ( + username TEXT PRIMARY KEY, + rules TEXT NOT NULL, -- JSON array: [{"keyword": "xxx", "type": "normal", "enabled": true}] + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +-- 7. 通知表 +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('favorite_update', 'system', 'announcement', 'movie_request', 'request_fulfilled')), + title TEXT NOT NULL, + message TEXT NOT NULL, + timestamp BIGINT NOT NULL, + read INTEGER DEFAULT 0, + metadata TEXT, -- JSON object + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC); + +-- 8. 求片请求表 +CREATE TABLE IF NOT EXISTS movie_requests ( + id TEXT PRIMARY KEY, + tmdb_id INTEGER, + title TEXT NOT NULL, + year TEXT, + media_type TEXT NOT NULL CHECK(media_type IN ('movie', 'tv')), + season INTEGER, + poster TEXT, + overview TEXT, + requested_by TEXT NOT NULL, -- JSON array: ["user1", "user2"] + request_count INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL CHECK(status IN ('pending', 'fulfilled')) DEFAULT 'pending', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + fulfilled_at BIGINT, + fulfilled_source TEXT, + fulfilled_id TEXT +); + +CREATE INDEX IF NOT EXISTS idx_movie_requests_status ON movie_requests(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_movie_requests_tmdb ON movie_requests(tmdb_id) WHERE tmdb_id IS NOT NULL; + +-- 9. 用户求片关联表(用于快速查询用户的求片记录) +CREATE TABLE IF NOT EXISTS user_movie_requests ( + username TEXT NOT NULL, + request_id TEXT NOT NULL, + PRIMARY KEY (username, request_id), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE, + FOREIGN KEY (request_id) REFERENCES movie_requests(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_movie_requests_user ON user_movie_requests(username); + +-- 10. 全局配置表(键值对存储) +CREATE TABLE IF NOT EXISTS global_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at BIGINT NOT NULL +); + +-- 11. 管理员配置表(单例) +CREATE TABLE IF NOT EXISTS admin_config ( + id INTEGER PRIMARY KEY CHECK(id = 1), -- 确保只有一条记录 + config TEXT NOT NULL, -- JSON object + updated_at BIGINT NOT NULL +); + +-- 12. 收藏更新检查时间表 +CREATE TABLE IF NOT EXISTS favorite_check_times ( + username TEXT PRIMARY KEY, + last_check_time BIGINT NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); diff --git a/next.config.js b/next.config.js index 2b16969..d2f1279 100644 --- a/next.config.js +++ b/next.config.js @@ -71,11 +71,13 @@ const nextConfig = { crypto: false, }; - // Exclude better-sqlite3 and D1 modules from client-side bundle + // Exclude better-sqlite3, D1, and Postgres modules from client-side bundle if (!isServer) { config.externals = config.externals || []; config.externals.push({ 'better-sqlite3': 'commonjs better-sqlite3', + '@vercel/postgres': 'commonjs @vercel/postgres', + 'pg': 'commonjs pg', }); config.resolve.alias = { @@ -83,6 +85,8 @@ const nextConfig = { 'better-sqlite3': false, '@/lib/d1.db': false, '@/lib/d1-adapter': false, + '@/lib/postgres.db': false, + '@/lib/postgres-adapter': false, }; } diff --git a/package.json b/package.json index 5272da3..df2c551 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "prepare": "husky install", "watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY", "init:sqlite": "node scripts/init-sqlite.js", + "init:postgres": "node scripts/init-postgres.js", "db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js" }, "dependencies": { @@ -91,6 +92,7 @@ "@types/node": "24.0.3", "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^7.0.5", + "@types/pg": "^8.16.0", "@types/react": "^18.3.18", "@types/react-dom": "^19.1.6", "@types/react-window": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51714b8..8a4d88e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: '@types/nodemailer': specifier: ^7.0.5 version: 7.0.5 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 '@types/react': specifier: ^18.3.18 version: 18.3.27 @@ -270,6 +273,12 @@ importers: vercel: specifier: ^50.4.10 version: 50.4.10(rollup@2.79.2)(typescript@4.9.5) + vite: + specifier: ^5.4.19 + version: 5.4.21(@types/node@24.0.3)(terser@5.44.1) + vite-plugin-pwa: + specifier: ^0.20.5 + version: 0.20.5(vite@5.4.21(@types/node@24.0.3)(terser@5.44.1))(workbox-build@6.6.0(@types/babel__core@7.20.5))(workbox-window@6.6.0) webpack-obfuscator: specifier: ^3.5.1 version: 3.6.0(javascript-obfuscator@5.1.0)(webpack@5.104.1) @@ -1588,6 +1597,12 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} engines: {node: '>=18'} @@ -1600,6 +1615,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.4': resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} engines: {node: '>=18'} @@ -1612,6 +1633,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.4': resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} engines: {node: '>=18'} @@ -1624,6 +1651,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.4': resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} engines: {node: '>=18'} @@ -1636,6 +1669,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.4': resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} engines: {node: '>=18'} @@ -1648,6 +1687,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.4': resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} engines: {node: '>=18'} @@ -1660,6 +1705,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.4': resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} engines: {node: '>=18'} @@ -1672,6 +1723,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} engines: {node: '>=18'} @@ -1684,6 +1741,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.4': resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} engines: {node: '>=18'} @@ -1696,6 +1759,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.4': resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} engines: {node: '>=18'} @@ -1708,6 +1777,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.4': resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} engines: {node: '>=18'} @@ -1720,6 +1795,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.4': resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} engines: {node: '>=18'} @@ -1732,6 +1813,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.4': resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} engines: {node: '>=18'} @@ -1744,6 +1831,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.4': resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} engines: {node: '>=18'} @@ -1756,6 +1849,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.4': resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} engines: {node: '>=18'} @@ -1768,6 +1867,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.4': resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} engines: {node: '>=18'} @@ -1780,6 +1885,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.4': resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} engines: {node: '>=18'} @@ -1804,6 +1915,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} engines: {node: '>=18'} @@ -1828,6 +1945,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} engines: {node: '>=18'} @@ -1846,6 +1969,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.4': resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} engines: {node: '>=18'} @@ -1858,6 +1987,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.4': resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} engines: {node: '>=18'} @@ -1870,6 +2005,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.4': resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} engines: {node: '>=18'} @@ -1882,6 +2023,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.4': resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} engines: {node: '>=18'} @@ -2571,6 +2718,131 @@ packages: rollup: optional: true + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3259,6 +3531,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/prettier@2.7.3': resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} @@ -4791,6 +5066,11 @@ packages: es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -6922,6 +7202,17 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -7024,6 +7315,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -7098,6 +7405,10 @@ packages: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7369,6 +7680,11 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -8301,6 +8617,49 @@ packages: resolution: {integrity: sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==} engines: {node: '>=16'} + vite-plugin-pwa@0.20.5: + resolution: {integrity: sha512-aweuI/6G6n4C5Inn0vwHumElU/UEpNuO+9iZzwPZGTCH87TeZ6YFMrEY6ZUBQdIHHlhTsbMDryFARcSuOdsz9Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^0.2.6 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 + workbox-build: ^7.1.0 + workbox-window: ^7.1.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. @@ -8576,6 +8935,10 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -11135,102 +11498,153 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.4': optional: true '@esbuild/aix-ppc64@0.27.0': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.4': optional: true '@esbuild/android-arm64@0.27.0': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.4': optional: true '@esbuild/android-arm@0.27.0': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.4': optional: true '@esbuild/android-x64@0.27.0': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.4': optional: true '@esbuild/darwin-arm64@0.27.0': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.4': optional: true '@esbuild/darwin-x64@0.27.0': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.4': optional: true '@esbuild/freebsd-arm64@0.27.0': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.4': optional: true '@esbuild/freebsd-x64@0.27.0': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.4': optional: true '@esbuild/linux-arm64@0.27.0': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.4': optional: true '@esbuild/linux-arm@0.27.0': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.4': optional: true '@esbuild/linux-ia32@0.27.0': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.4': optional: true '@esbuild/linux-loong64@0.27.0': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.4': optional: true '@esbuild/linux-mips64el@0.27.0': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.4': optional: true '@esbuild/linux-ppc64@0.27.0': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.4': optional: true '@esbuild/linux-riscv64@0.27.0': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.4': optional: true '@esbuild/linux-s390x@0.27.0': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.4': optional: true @@ -11243,6 +11657,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.4': optional: true @@ -11255,6 +11672,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.4': optional: true @@ -11264,24 +11684,36 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.4': optional: true '@esbuild/sunos-x64@0.27.0': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.4': optional: true '@esbuild/win32-arm64@0.27.0': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.4': optional: true '@esbuild/win32-ia32@0.27.0': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.4': optional: true @@ -12036,6 +12468,81 @@ snapshots: optionalDependencies: rollup: 2.79.2 + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.15.0': {} @@ -13020,6 +13527,12 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/pg@8.16.0': + dependencies: + '@types/node': 24.0.3 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/prettier@2.7.3': {} '@types/prop-types@15.7.15': {} @@ -14839,6 +15352,32 @@ snapshots: es6-promise@4.2.8: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -17714,6 +18253,18 @@ snapshots: pend@1.2.0: {} + pg-int8@1.0.1: {} + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.0.0: {} picocolors@1.1.1: {} @@ -17792,6 +18343,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -17819,6 +18380,8 @@ snapshots: pretty-bytes@5.6.0: {} + pretty-bytes@6.1.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -18146,6 +18709,37 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3(supports-color@9.4.0) @@ -19294,6 +19888,27 @@ snapshots: media-captions: 0.0.18 type-fest: 3.13.1 + vite-plugin-pwa@0.20.5(vite@5.4.21(@types/node@24.0.3)(terser@5.44.1))(workbox-build@6.6.0(@types/babel__core@7.20.5))(workbox-window@6.6.0): + dependencies: + debug: 4.4.3(supports-color@9.4.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.15 + vite: 5.4.21(@types/node@24.0.3)(terser@5.44.1) + workbox-build: 6.6.0(@types/babel__core@7.20.5) + workbox-window: 6.6.0 + transitivePeerDependencies: + - supports-color + + vite@5.4.21(@types/node@24.0.3)(terser@5.44.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + '@types/node': 24.0.3 + fsevents: 2.3.3 + terser: 5.44.1 + w3c-hr-time@1.0.2: dependencies: browser-process-hrtime: 1.0.0 @@ -19662,6 +20277,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/scripts/init-postgres.js b/scripts/init-postgres.js new file mode 100644 index 0000000..bb677de --- /dev/null +++ b/scripts/init-postgres.js @@ -0,0 +1,62 @@ +/** + * Vercel Postgres 数据库初始化脚本 + * + * 创建数据库表结构并初始化默认管理员用户 + */ + +const { sql } = require('@vercel/postgres'); +const crypto = require('crypto'); + +// SHA-256 加密密码 +function hashPassword(password) { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +console.log('📦 Initializing Vercel Postgres database...'); + +// 读取迁移脚本 +const fs = require('fs'); +const path = require('path'); + +const sqlPath = path.join(__dirname, '../migrations/postgres/001_initial_schema.sql'); +if (!fs.existsSync(sqlPath)) { + console.error('❌ Migration file not found:', sqlPath); + process.exit(1); +} + +const schemaSql = fs.readFileSync(sqlPath, 'utf8'); + +async function init() { + try { + // 执行 schema 创建 + console.log('🔧 Creating database schema...'); + await sql.unsafe(schemaSql); + console.log('✅ Database schema created successfully!'); + + // 创建默认管理员用户 + const username = process.env.USERNAME || 'admin'; + const password = process.env.PASSWORD || '123456789'; + const passwordHash = hashPassword(password); + + console.log('👤 Creating default admin user...'); + await sql` + INSERT INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated) + VALUES (${username}, ${passwordHash}, 'owner', ${Date.now()}, 1, 1, 1) + ON CONFLICT (username) DO NOTHING + `; + console.log(`✅ Default admin user created: ${username}`); + + console.log(''); + console.log('🎉 Vercel Postgres database initialized successfully!'); + console.log(''); + console.log('Next steps:'); + console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=postgres in .env'); + console.log('2. Set POSTGRES_URL environment variable'); + console.log('3. Run: npm run dev'); + } catch (err) { + console.error('❌ Initialization failed:', err); + process.exit(1); + } +} + +init(); diff --git a/src/lib/db.ts b/src/lib/db.ts index 5a9e47e..3ef2b22 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -7,7 +7,7 @@ import { RedisStorage } from './redis.db'; import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { UpstashRedisStorage } from './upstash.db'; -// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1',默认 'localstorage' +// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage' const STORAGE_TYPE = (process.env.NEXT_PUBLIC_STORAGE_TYPE as | 'localstorage' @@ -15,6 +15,7 @@ const STORAGE_TYPE = | 'upstash' | 'kvrocks' | 'd1' + | 'postgres' | undefined) || 'localstorage'; // 创建存储实例 @@ -31,16 +32,38 @@ function createStorage(): IStorage { if (typeof window !== 'undefined') { throw new Error('D1Storage can only be used on the server side'); } - const adapter = getD1Adapter(); + const d1Adapter = getD1Adapter(); // 动态导入 D1Storage 以避免客户端打包 const { D1Storage } = require('./d1.db'); - return new D1Storage(adapter); + return new D1Storage(d1Adapter); + case 'postgres': + // PostgresStorage 只能在服务端使用,客户端会报错 + if (typeof window !== 'undefined') { + throw new Error('PostgresStorage can only be used on the server side'); + } + const postgresAdapter = getPostgresAdapter(); + // 动态导入 PostgresStorage 以避免客户端打包 + const { PostgresStorage } = require('./postgres.db'); + return new PostgresStorage(postgresAdapter); case 'localstorage': default: return null as unknown as IStorage; } } +/** + * 获取 Postgres 适配器 + * 使用 Vercel Postgres (@vercel/postgres) + */ +function getPostgresAdapter(): any { + // 动态导入适配器以避免客户端打包 + const { PostgresAdapter } = require('./postgres-adapter'); + + console.log('Using Vercel Postgres database'); + + return new PostgresAdapter(); +} + /** * 获取 D1 适配器 * 开发环境:使用 better-sqlite3 diff --git a/src/lib/postgres-adapter.ts b/src/lib/postgres-adapter.ts new file mode 100644 index 0000000..688b20d --- /dev/null +++ b/src/lib/postgres-adapter.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Vercel Postgres (Neon/Postgres) 适配器 + * + * 将 Vercel Postgres API 转换为与 D1 兼容的接口 + * + * 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包 + */ + +import { sql } from '@vercel/postgres'; +import { DatabaseAdapter, D1PreparedStatement, D1Result } from './d1-adapter'; + +/** + * Vercel Postgres 适配器 + * + * 使用 @vercel/postgres 包装为 D1 兼容接口 + */ +export class PostgresAdapter implements DatabaseAdapter { + private queryParams: { query: string; values: any[] } | null = null; + + prepare(query: string): D1PreparedStatement { + return new PostgresPreparedStatement(query); + } + + batch(statements: D1PreparedStatement[]): Promise { + // Postgres 使用事务模拟 batch + return new Promise((resolve, reject) => { + Promise.all(statements.map((stmt) => (stmt as PostgresPreparedStatement).execute())) + .then((results) => resolve(results)) + .catch((err) => reject(err)); + }); + } + + exec(query: string): void { + // Vercel Postgres 不支持直接 exec,需要使用 sql 模板 + throw new Error('exec() is not supported for Vercel Postgres. Use prepare() instead.'); + } +} + +/** + * Vercel Postgres PreparedStatement 包装器 + * 将 Vercel Postgres API 转换为 D1 兼容 API + */ +class PostgresPreparedStatement implements D1PreparedStatement { + private params: any[] = []; + private paramIndex = 1; + + constructor(private query: string) {} + + bind(...values: any[]): D1PreparedStatement { + this.params = values; + return this; + } + + /** + * 将 SQLite 风格的 ? 占位符替换为 Postgres 风格的 $1, $2, ... + */ + private convertQuery(query: string): string { + let index = 1; + return query.replace(/\?/g, () => `$${index++}`); + } + + /** + * 将 SQL 查询中的表名和列名转换为双引号包裹(Postgres 要求) + * 注意:需要排除已经有引号的内容 + */ + private quoteIdentifiers(query: string): string { + // 这个方法主要用于处理列值,表名在 schema 中已经创建好 + return query; + } + + /** + * 执行查询并返回第一行 + */ + async first(colName?: string): Promise { + try { + const convertedQuery = this.convertQuery(this.query); + + // 使用 Vercel Postgres 的 unsafe 方法执行参数化查询 + const result = await sql.unsafe(convertedQuery, this.params); + + if (!result || result.rows.length === 0) return null; + + const row = result.rows[0]; + + if (colName) return row[colName] ?? null; + + return row as T; + } catch (err) { + console.error('Postgres first() error:', err); + return null; + } + } + + /** + * 执行查询并返回结果 + */ + async run(): Promise> { + try { + const convertedQuery = this.convertQuery(this.query); + + const result = await sql.unsafe(convertedQuery, this.params); + + return { + success: true, + meta: { + changes: result.rowCount || 0, + last_row_id: null, // Postgres 不直接返回 lastInsertId + }, + results: result.rows, + }; + } catch (err: any) { + console.error('Postgres run() error:', err); + return { + success: false, + error: err.message, + }; + } + } + + /** + * 执行查询并返回所有行 + */ + async all(): Promise> { + try { + const convertedQuery = this.convertQuery(this.query); + + const result = await sql.unsafe(convertedQuery, this.params); + + return { + success: true, + results: result.rows || [], + }; + } catch (err: any) { + console.error('Postgres all() error:', err); + return { + success: false, + error: err.message, + results: [], + }; + } + } + + /** + * 内部执行方法(用于 batch 操作) + */ + async execute(): Promise { + return this.run(); + } +} diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts new file mode 100644 index 0000000..6c9e696 --- /dev/null +++ b/src/lib/postgres.db.ts @@ -0,0 +1,1561 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + +/** + * Vercel Postgres Storage Implementation + * + * 兼容 D1Storage 的接口,使用 Vercel Postgres 作为后端 + * + * 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包 + */ + +import { + IStorage, + PlayRecord, + Favorite, + SkipConfig, + DanmakuFilterConfig, + Notification, + MovieRequest, +} from './types'; +import { AdminConfig } from './admin.types'; +import { DatabaseAdapter } from './d1-adapter'; + +/** + * Vercel Postgres 存储实现 + * + * 特点: + * - 兼容 D1Storage 的所有接口 + * - 使用 Vercel Postgres (Neon) 作为数据库 + * - 支持 Vercel serverless 部署 + * + * 使用方式: + * 1. 设置环境变量:NEXT_PUBLIC_STORAGE_TYPE=postgres + * 2. 配置 POSTGRES_URL 环境变量 + * 3. 运行数据库迁移脚本 + */ +export class PostgresStorage implements IStorage { + private db: DatabaseAdapter; + public adapter: any; // 用于兼容 + + constructor(adapter: DatabaseAdapter) { + this.db = adapter; + // 创建一个简单的适配器用于设备管理 + this.adapter = new PostgresRedisHashAdapter(adapter); + } + + // ==================== 播放记录 ==================== + + async getPlayRecord(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM play_records WHERE username = $1 AND key = $2') + .bind(userName, key) + .first(); + + if (!result) return null; + return this.rowToPlayRecord(result); + } catch (err) { + console.error('PostgresStorage.getPlayRecord error:', err); + throw err; + } + } + + async setPlayRecord(userName: string, key: string, record: PlayRecord): Promise { + try { + await this.db + .prepare(` + INSERT INTO play_records ( + username, key, title, source_name, cover, year, + episode_index, total_episodes, play_time, total_time, + save_time, search_title + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (username, key) DO UPDATE SET + title = EXCLUDED.title, + source_name = EXCLUDED.source_name, + cover = EXCLUDED.cover, + year = EXCLUDED.year, + episode_index = EXCLUDED.episode_index, + total_episodes = EXCLUDED.total_episodes, + play_time = EXCLUDED.play_time, + total_time = EXCLUDED.total_time, + save_time = EXCLUDED.save_time, + search_title = EXCLUDED.search_title + `) + .bind( + userName, + key, + record.title, + record.source_name, + record.cover || '', + record.year || '', + record.index, + record.total_episodes, + record.play_time, + record.total_time, + record.save_time, + record.search_title || '' + ) + .run(); + } catch (err) { + console.error('PostgresStorage.setPlayRecord error:', err); + throw err; + } + } + + async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }> { + try { + const results = await this.db + .prepare('SELECT * FROM play_records WHERE username = $1 ORDER BY save_time DESC') + .bind(userName) + .all(); + + const records: { [key: string]: PlayRecord } = {}; + if (results.results) { + for (const row of results.results) { + const record = this.rowToPlayRecord(row); + records[row.key as string] = record; + } + } + return records; + } catch (err) { + console.error('PostgresStorage.getAllPlayRecords error:', err); + throw err; + } + } + + async deletePlayRecord(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM play_records WHERE username = $1 AND key = $2') + .bind(userName, key) + .run(); + } catch (err) { + console.error('PostgresStorage.deletePlayRecord error:', err); + throw err; + } + } + + async cleanupOldPlayRecords(userName: string): Promise { + try { + const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10); + const threshold = maxRecords + 10; + + // 检查记录数量 + const countResult = await this.db + .prepare('SELECT COUNT(*) as count FROM play_records WHERE username = $1') + .bind(userName) + .first(); + + const count = (countResult?.count as number) || 0; + if (count <= threshold) return; + + // 删除超出限制的旧记录 + await this.db + .prepare(` + DELETE FROM play_records + WHERE username = $1 + AND key NOT IN ( + SELECT key FROM play_records + WHERE username = $1 + ORDER BY save_time DESC + LIMIT $2 + ) + `) + .bind(userName, maxRecords) + .run(); + + console.log(`PostgresStorage: Cleaned up old play records for user ${userName}`); + } catch (err) { + console.error('PostgresStorage.cleanupOldPlayRecords error:', err); + throw err; + } + } + + async migratePlayRecords(userName: string): Promise { + try { + await this.db + .prepare('UPDATE users SET playrecord_migrated = 1 WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.migratePlayRecords error:', err); + } + } + + // ==================== 收藏 ==================== + + async getFavorite(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM favorites WHERE username = $1 AND key = $2') + .bind(userName, key) + .first(); + + if (!result) return null; + return this.rowToFavorite(result); + } catch (err) { + console.error('PostgresStorage.getFavorite error:', err); + throw err; + } + } + + async setFavorite(userName: string, key: string, favorite: Favorite): Promise { + try { + await this.db + .prepare(` + INSERT INTO favorites ( + username, key, source_name, total_episodes, title, + year, cover, save_time, search_title, origin, + is_completed, vod_remarks + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (username, key) DO UPDATE SET + source_name = EXCLUDED.source_name, + total_episodes = EXCLUDED.total_episodes, + title = EXCLUDED.title, + year = EXCLUDED.year, + cover = EXCLUDED.cover, + save_time = EXCLUDED.save_time, + search_title = EXCLUDED.search_title, + origin = EXCLUDED.origin, + is_completed = EXCLUDED.is_completed, + vod_remarks = EXCLUDED.vod_remarks + `) + .bind( + userName, + key, + favorite.source_name, + favorite.total_episodes, + favorite.title, + favorite.year || '', + favorite.cover || '', + favorite.save_time, + favorite.search_title || '', + favorite.origin || null, + favorite.is_completed ? 1 : 0, + favorite.vod_remarks || null + ) + .run(); + } catch (err) { + console.error('PostgresStorage.setFavorite error:', err); + throw err; + } + } + + async getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }> { + try { + const results = await this.db + .prepare('SELECT * FROM favorites WHERE username = $1 ORDER BY save_time DESC') + .bind(userName) + .all(); + + const favorites: { [key: string]: Favorite } = {}; + if (results.results) { + for (const row of results.results) { + const favorite = this.rowToFavorite(row); + favorites[row.key as string] = favorite; + } + } + return favorites; + } catch (err) { + console.error('PostgresStorage.getAllFavorites error:', err); + throw err; + } + } + + async deleteFavorite(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM favorites WHERE username = $1 AND key = $2') + .bind(userName, key) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteFavorite error:', err); + throw err; + } + } + + async migrateFavorites(userName: string): Promise { + try { + await this.db + .prepare('UPDATE users SET favorite_migrated = 1 WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.migrateFavorites error:', err); + } + } + + // ==================== 辅助方法 ==================== + + private rowToPlayRecord(row: any): PlayRecord { + return { + title: row.title, + source_name: row.source_name, + cover: row.cover || '', + year: row.year || '', + index: row.episode_index, + total_episodes: row.total_episodes, + play_time: row.play_time, + total_time: row.total_time, + save_time: row.save_time, + search_title: row.search_title || '', + }; + } + + private rowToFavorite(row: any): Favorite { + return { + source_name: row.source_name, + total_episodes: row.total_episodes, + title: row.title, + year: row.year || '', + cover: row.cover || '', + save_time: row.save_time, + search_title: row.search_title || '', + origin: row.origin as 'vod' | 'live' | undefined, + is_completed: row.is_completed === 1, + vod_remarks: row.vod_remarks || undefined, + }; + } + + // ==================== 用户管理 ==================== + + private async hashPassword(password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(password); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + async verifyUser(userName: string, password: string): Promise { + try { + // 检查是否是环境变量中的管理员 + if (userName === process.env.USERNAME && password === process.env.PASSWORD) { + return true; + } + + const user = await this.db + .prepare('SELECT password_hash FROM users WHERE username = $1 AND banned = 0') + .bind(userName) + .first(); + + if (!user || !user.password_hash) return false; + + // 使用 SHA-256 验证密码(与 Redis 保持一致) + const hashedPassword = await this.hashPassword(password); + return user.password_hash === hashedPassword; + } catch (err) { + console.error('PostgresStorage.verifyUser error:', err); + return false; + } + } + + async checkUserExist(userName: string): Promise { + try { + // 检查环境变量 + if (userName === process.env.USERNAME) { + return true; + } + + const result = await this.db + .prepare('SELECT 1 FROM users WHERE username = $1 LIMIT 1') + .bind(userName) + .first(); + + return result !== null; + } catch (err) { + console.error('PostgresStorage.checkUserExist error:', err); + return false; + } + } + + async changePassword(userName: string, newPassword: string): Promise { + try { + const passwordHash = await this.hashPassword(newPassword); + + await this.db + .prepare('UPDATE users SET password_hash = $1 WHERE username = $2') + .bind(passwordHash, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.changePassword error:', err); + throw err; + } + } + + async deleteUser(userName: string): Promise { + try { + // 由于设置了 ON DELETE CASCADE,删除用户会自动删除相关数据 + await this.db + .prepare('DELETE FROM users WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteUser error:', err); + throw err; + } + } + + async getAllUsers(): Promise { + try { + const results = await this.db + .prepare('SELECT username FROM users ORDER BY created_at DESC') + .all(); + + if (!results.results) return []; + return results.results.map((row) => row.username as string); + } catch (err) { + console.error('PostgresStorage.getAllUsers error:', err); + return []; + } + } + + async getUserInfoV2(userName: string): Promise { + try { + // 先尝试从数据库获取用户信息 + const user = await this.db + .prepare('SELECT * FROM users WHERE username = $1') + .bind(userName) + .first(); + + if (user) { + return { + role: user.role as 'owner' | 'admin' | 'user', + banned: user.banned === 1, + tags: user.tags ? JSON.parse(user.tags as string) : undefined, + oidcSub: user.oidc_sub as string | undefined, + enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + created_at: user.created_at as number, + playrecord_migrated: user.playrecord_migrated === 1, + favorite_migrated: user.favorite_migrated === 1, + skip_migrated: user.skip_migrated === 1, + last_movie_request_time: user.last_movie_request_time as number | undefined, + email: user.email as string | undefined, + emailNotifications: user.email_notifications === 1, + }; + } + + // 如果数据库中没有,检查是否是环境变量中的站长 + if (userName === process.env.USERNAME) { + return { + role: 'owner', + banned: false, + created_at: 0, + playrecord_migrated: true, + favorite_migrated: true, + skip_migrated: true, + }; + } + + return null; + } catch (err) { + console.error('PostgresStorage.getUserInfoV2 error:', err); + return null; + } + } + + async createUserV2( + userName: string, + password: string, + role: 'owner' | 'admin' | 'user', + tags?: string[], + oidcSub?: string, + enabledApis?: string[] + ): Promise { + try { + const passwordHash = await this.hashPassword(password); + + await this.db + .prepare(` + INSERT INTO users ( + username, password_hash, role, banned, tags, oidc_sub, + enabled_apis, created_at, playrecord_migrated, + favorite_migrated, skip_migrated + ) + VALUES ($1, $2, $3, 0, $4, $5, $6, $7, 1, 1, 1) + `) + .bind( + userName, + passwordHash, + role, + tags ? JSON.stringify(tags) : null, + oidcSub || null, + enabledApis ? JSON.stringify(enabledApis) : null, + Date.now() + ) + .run(); + } catch (err) { + console.error('PostgresStorage.createUserV2 error:', err); + throw err; + } + } + + async getUserListV2( + offset = 0, + limit = 20, + ownerUsername?: string + ): Promise<{ + users: Array<{ + username: string; + role: 'owner' | 'admin' | 'user'; + banned: boolean; + tags?: string[]; + oidcSub?: string; + enabledApis?: string[]; + created_at: number; + }>; + total: number; + }> { + try { + // 获取总数 + const countResult = await this.db + .prepare('SELECT COUNT(*) as total FROM users') + .first(); + let total = (countResult?.total as number) || 0; + + // 检查站长是否在数据库中 + let ownerInfo = null; + let ownerInDatabase = false; + if (ownerUsername) { + ownerInfo = await this.getUserInfoV2(ownerUsername); + ownerInDatabase = !!ownerInfo && ownerInfo.created_at !== 0; + + // 如果站长不在数据库中,总数+1 + if (!ownerInDatabase) { + total += 1; + } + } + + // 调整偏移量和限制 + let actualOffset = offset; + let actualLimit = limit; + + if (ownerUsername && !ownerInDatabase) { + if (offset === 0) { + // 第一页:只获取 limit-1 个用户,为站长留出位置 + actualLimit = limit - 1; + } else { + // 其他页:偏移量需要减1,因为站长占据了第一页的一个位置 + actualOffset = offset - 1; + } + } + + // 获取用户列表(按创建时间降序) + const result = await this.db + .prepare(` + SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at + FROM users + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `) + .bind(actualLimit, actualOffset) + .all(); + + const users = []; + + // 如果有站长且在第一页,确保站长始终在第一位 + if (ownerUsername && offset === 0) { + users.push({ + username: ownerUsername, + role: 'owner' as const, + banned: ownerInfo?.banned || false, + tags: ownerInfo?.tags, + oidcSub: ownerInfo?.oidcSub, + enabledApis: ownerInfo?.enabledApis, + created_at: ownerInfo?.created_at || 0, + }); + } + + // 添加其他用户 + if (result.results) { + for (const user of result.results) { + // 跳过站长(已经添加) + if (ownerUsername && user.username === ownerUsername) { + continue; + } + + users.push({ + username: user.username as string, + role: user.role as 'owner' | 'admin' | 'user', + banned: user.banned === 1, + tags: user.tags ? JSON.parse(user.tags as string) : undefined, + oidcSub: user.oidc_sub as string | undefined, + enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + created_at: user.created_at as number, + }); + } + } + + return { users, total }; + } catch (err) { + console.error('PostgresStorage.getUserListV2 error:', err); + return { users: [], total: 0 }; + } + } + + async verifyUserV2(userName: string, password: string): Promise { + try { + const user = await this.db + .prepare('SELECT password_hash FROM users WHERE username = $1') + .bind(userName) + .first(); + + if (!user) return false; + + const hashedPassword = await this.hashPassword(password); + return user.password_hash === hashedPassword; + } catch (err) { + console.error('PostgresStorage.verifyUserV2 error:', err); + return false; + } + } + + async updateUserInfoV2( + userName: string, + updates: { + role?: 'owner' | 'admin' | 'user'; + banned?: boolean; + tags?: string[]; + oidcSub?: string; + enabledApis?: string[]; + } + ): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.role !== undefined) { + fields.push(`role = $${paramIndex++}`); + values.push(updates.role); + } + if (updates.banned !== undefined) { + fields.push(`banned = $${paramIndex++}`); + values.push(updates.banned ? 1 : 0); + } + if (updates.tags !== undefined) { + fields.push(`tags = $${paramIndex++}`); + values.push(JSON.stringify(updates.tags)); + } + if (updates.oidcSub !== undefined) { + fields.push(`oidc_sub = $${paramIndex++}`); + values.push(updates.oidcSub); + } + if (updates.enabledApis !== undefined) { + fields.push(`enabled_apis = $${paramIndex++}`); + values.push(JSON.stringify(updates.enabledApis)); + } + + if (fields.length === 0) return; + + values.push(userName); + + await this.db + .prepare(`UPDATE users SET ${fields.join(', ')} WHERE username = $${paramIndex}`) + .bind(...values) + .run(); + } catch (err) { + console.error('PostgresStorage.updateUserInfoV2 error:', err); + throw err; + } + } + + async changePasswordV2(userName: string, newPassword: string): Promise { + try { + const passwordHash = await this.hashPassword(newPassword); + + await this.db + .prepare('UPDATE users SET password_hash = $1 WHERE username = $2') + .bind(passwordHash, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.changePasswordV2 error:', err); + throw err; + } + } + + async checkUserExistV2(userName: string): Promise { + try { + const user = await this.db + .prepare('SELECT 1 FROM users WHERE username = $1') + .bind(userName) + .first(); + + return !!user; + } catch (err) { + console.error('PostgresStorage.checkUserExistV2 error:', err); + return false; + } + } + + async getUserByOidcSub(oidcSub: string): Promise { + try { + const user = await this.db + .prepare('SELECT username FROM users WHERE oidc_sub = $1') + .bind(oidcSub) + .first(); + + return user ? (user.username as string) : null; + } catch (err) { + console.error('PostgresStorage.getUserByOidcSub error:', err); + return null; + } + } + + async deleteUserV2(userName: string): Promise { + try { + // Postgres 的外键约束会自动级联删除相关数据 + await this.db + .prepare('DELETE FROM users WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteUserV2 error:', err); + throw err; + } + } + + async getUsersByTag(tagName: string): Promise { + try { + // Postgres 支持 JSON 查询 + const result = await this.db + .prepare(` + SELECT username FROM users + WHERE tags::jsonb ? $1 + `) + .bind(tagName) + .all(); + + if (!result.results) return []; + + return result.results.map((row: any) => row.username as string); + } catch (err) { + console.error('PostgresStorage.getUsersByTag error:', err); + return []; + } + } + + async getUserPasswordHash(userName: string): Promise { + try { + const user = await this.db + .prepare('SELECT password_hash FROM users WHERE username = $1') + .bind(userName) + .first(); + + return user ? (user.password_hash as string) : null; + } catch (err) { + console.error('PostgresStorage.getUserPasswordHash error:', err); + return null; + } + } + + async setUserPasswordHash(userName: string, passwordHash: string): Promise { + try { + await this.db + .prepare('UPDATE users SET password_hash = $1 WHERE username = $2') + .bind(passwordHash, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.setUserPasswordHash error:', err); + throw err; + } + } + + async createUserWithHashedPassword( + userName: string, + passwordHash: string, + role: 'owner' | 'admin' | 'user', + createdAt: number, + tags?: string[], + oidcSub?: string, + enabledApis?: string[], + banned?: boolean + ): Promise { + try { + await this.db + .prepare(` + INSERT INTO users ( + username, password_hash, role, banned, tags, oidc_sub, + enabled_apis, created_at, playrecord_migrated, + favorite_migrated, skip_migrated + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 1, 1) + `) + .bind( + userName, + passwordHash, + role, + banned ? 1 : 0, + tags ? JSON.stringify(tags) : null, + oidcSub || null, + enabledApis ? JSON.stringify(enabledApis) : null, + createdAt + ) + .run(); + } catch (err) { + console.error('PostgresStorage.createUserWithHashedPassword error:', err); + throw err; + } + } + + async getUserEmail(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT email FROM users WHERE username = $1') + .bind(userName) + .first(); + + return result?.email as string | null; + } catch (err) { + console.error('PostgresStorage.getUserEmail error:', err); + return null; + } + } + + async setUserEmail(userName: string, email: string): Promise { + try { + await this.db + .prepare('UPDATE users SET email = $1 WHERE username = $2') + .bind(email, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.setUserEmail error:', err); + throw err; + } + } + + async getEmailNotificationPreference(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT email_notifications FROM users WHERE username = $1') + .bind(userName) + .first(); + + return result?.email_notifications === 1; + } catch (err) { + console.error('PostgresStorage.getEmailNotificationPreference error:', err); + return true; // 默认开启 + } + } + + async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { + try { + await this.db + .prepare('UPDATE users SET email_notifications = $1 WHERE username = $2') + .bind(enabled ? 1 : 0, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.setEmailNotificationPreference error:', err); + throw err; + } + } + + // ==================== 搜索历史 ==================== + + async getSearchHistory(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT keyword FROM search_history WHERE username = $1 ORDER BY timestamp DESC LIMIT 20') + .bind(userName) + .all(); + + if (!results.results) return []; + return results.results.map((row) => row.keyword as string); + } catch (err) { + console.error('PostgresStorage.getSearchHistory error:', err); + return []; + } + } + + async addSearchHistory(userName: string, keyword: string): Promise { + try { + const timestamp = Date.now(); + + // 插入或更新时间戳 + await this.db + .prepare(` + INSERT INTO search_history (username, keyword, timestamp) + VALUES ($1, $2, $3) + ON CONFLICT (username, keyword) DO UPDATE SET timestamp = EXCLUDED.timestamp + `) + .bind(userName, keyword, timestamp) + .run(); + + // 保持最多 20 条记录 + const countResult = await this.db + .prepare('SELECT COUNT(*) as count FROM search_history WHERE username = $1') + .bind(userName) + .first(); + + const count = (countResult?.count as number) || 0; + if (count > 20) { + await this.db + .prepare(` + DELETE FROM search_history + WHERE username = $1 + AND id NOT IN ( + SELECT id FROM search_history + WHERE username = $1 + ORDER BY timestamp DESC + LIMIT 20 + ) + `) + .bind(userName) + .run(); + } + } catch (err) { + console.error('PostgresStorage.addSearchHistory error:', err); + throw err; + } + } + + async deleteSearchHistory(userName: string, keyword?: string): Promise { + try { + if (keyword) { + await this.db + .prepare('DELETE FROM search_history WHERE username = $1 AND keyword = $2') + .bind(userName, keyword) + .run(); + } else { + await this.db + .prepare('DELETE FROM search_history WHERE username = $1') + .bind(userName) + .run(); + } + } catch (err) { + console.error('PostgresStorage.deleteSearchHistory error:', err); + throw err; + } + } + + // ==================== 跳过配置 ==================== + + async getSkipConfig(userName: string, source: string, id: string): Promise { + try { + const key = `${source}+${id}`; + const result = await this.db + .prepare('SELECT * FROM skip_configs WHERE username = $1 AND key = $2') + .bind(userName, key) + .first(); + + if (!result) return null; + return { + enable: result.enable === 1, + intro_time: result.intro_time as number, + outro_time: result.outro_time as number, + }; + } catch (err) { + console.error('PostgresStorage.getSkipConfig error:', err); + return null; + } + } + + async setSkipConfig(userName: string, source: string, id: string, config: SkipConfig): Promise { + try { + const key = `${source}+${id}`; + await this.db + .prepare(` + INSERT INTO skip_configs (username, key, enable, intro_time, outro_time) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (username, key) DO UPDATE SET + enable = EXCLUDED.enable, + intro_time = EXCLUDED.intro_time, + outro_time = EXCLUDED.outro_time + `) + .bind(userName, key, config.enable ? 1 : 0, config.intro_time, config.outro_time) + .run(); + } catch (err) { + console.error('PostgresStorage.setSkipConfig error:', err); + throw err; + } + } + + async deleteSkipConfig(userName: string, source: string, id: string): Promise { + try { + const key = `${source}+${id}`; + await this.db + .prepare('DELETE FROM skip_configs WHERE username = $1 AND key = $2') + .bind(userName, key) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteSkipConfig error:', err); + throw err; + } + } + + async getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }> { + try { + const results = await this.db + .prepare('SELECT * FROM skip_configs WHERE username = $1') + .bind(userName) + .all(); + + const configs: { [key: string]: SkipConfig } = {}; + if (results.results) { + for (const row of results.results) { + configs[row.key as string] = { + enable: row.enable === 1, + intro_time: row.intro_time as number, + outro_time: row.outro_time as number, + }; + } + } + return configs; + } catch (err) { + console.error('PostgresStorage.getAllSkipConfigs error:', err); + return {}; + } + } + + async migrateSkipConfigs(userName: string): Promise { + try { + await this.db + .prepare('UPDATE users SET skip_migrated = 1 WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.migrateSkipConfigs error:', err); + } + } + + // ==================== 弹幕过滤配置 ==================== + + async getDanmakuFilterConfig(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = $1') + .bind(userName) + .first(); + + if (!result) return null; + return JSON.parse(result.rules as string); + } catch (err) { + console.error('PostgresStorage.getDanmakuFilterConfig error:', err); + return null; + } + } + + async setDanmakuFilterConfig(userName: string, config: DanmakuFilterConfig): Promise { + try { + await this.db + .prepare(` + INSERT INTO danmaku_filter_configs (username, rules) + VALUES ($1, $2) + ON CONFLICT (username) DO UPDATE SET rules = EXCLUDED.rules + `) + .bind(userName, JSON.stringify(config)) + .run(); + } catch (err) { + console.error('PostgresStorage.setDanmakuFilterConfig error:', err); + throw err; + } + } + + async deleteDanmakuFilterConfig(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM danmaku_filter_configs WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteDanmakuFilterConfig error:', err); + throw err; + } + } + + // ==================== 通知 ==================== + + async getNotifications(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM notifications WHERE username = $1 ORDER BY timestamp DESC') + .bind(userName) + .all(); + + if (!results.results) return []; + return results.results.map((row) => ({ + id: row.id as string, + type: row.type as any, + title: row.title as string, + message: row.message as string, + timestamp: row.timestamp as number, + read: row.read === 1, + metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, + })); + } catch (err) { + console.error('PostgresStorage.getNotifications error:', err); + return []; + } + } + + async addNotification(userName: string, notification: Notification): Promise { + try { + await this.db + .prepare(` + INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `) + .bind( + notification.id, + userName, + notification.type, + notification.title, + notification.message, + notification.timestamp, + notification.read ? 1 : 0, + notification.metadata ? JSON.stringify(notification.metadata) : null + ) + .run(); + } catch (err) { + console.error('PostgresStorage.addNotification error:', err); + throw err; + } + } + + async markNotificationAsRead(userName: string, notificationId: string): Promise { + try { + await this.db + .prepare('UPDATE notifications SET read = 1 WHERE username = $1 AND id = $2') + .bind(userName, notificationId) + .run(); + } catch (err) { + console.error('PostgresStorage.markNotificationAsRead error:', err); + throw err; + } + } + + async deleteNotification(userName: string, notificationId: string): Promise { + try { + await this.db + .prepare('DELETE FROM notifications WHERE username = $1 AND id = $2') + .bind(userName, notificationId) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteNotification error:', err); + throw err; + } + } + + async clearAllNotifications(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM notifications WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.clearAllNotifications error:', err); + throw err; + } + } + + async getUnreadNotificationCount(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT COUNT(*) as count FROM notifications WHERE username = $1 AND read = 0') + .bind(userName) + .first(); + + return (result?.count as number) || 0; + } catch (err) { + console.error('PostgresStorage.getUnreadNotificationCount error:', err); + return 0; + } + } + + // ==================== 求片请求 ==================== + + async getAllMovieRequests(): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM movie_requests ORDER BY created_at DESC') + .all(); + + if (!results.results) return []; + return results.results.map((row) => this.rowToMovieRequest(row)); + } catch (err) { + console.error('PostgresStorage.getAllMovieRequests error:', err); + return []; + } + } + + async getMovieRequest(requestId: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM movie_requests WHERE id = $1') + .bind(requestId) + .first(); + + if (!result) return null; + return this.rowToMovieRequest(result); + } catch (err) { + console.error('PostgresStorage.getMovieRequest error:', err); + return null; + } + } + + async createMovieRequest(request: MovieRequest): Promise { + try { + await this.db + .prepare(` + INSERT INTO movie_requests ( + id, tmdb_id, title, year, media_type, season, poster, overview, + requested_by, request_count, status, created_at, updated_at, + fulfilled_at, fulfilled_source, fulfilled_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + `) + .bind( + request.id, + request.tmdbId || null, + request.title, + request.year || null, + request.mediaType, + request.season || null, + request.poster || null, + request.overview || null, + JSON.stringify(request.requestedBy), + request.requestCount, + request.status, + request.createdAt, + request.updatedAt, + request.fulfilledAt || null, + request.fulfilledSource || null, + request.fulfilledId || null + ) + .run(); + } catch (err) { + console.error('PostgresStorage.createMovieRequest error:', err); + throw err; + } + } + + async updateMovieRequest(requestId: string, updates: Partial): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.requestedBy !== undefined) { + fields.push(`requested_by = $${paramIndex++}`); + values.push(JSON.stringify(updates.requestedBy)); + } + if (updates.requestCount !== undefined) { + fields.push(`request_count = $${paramIndex++}`); + values.push(updates.requestCount); + } + if (updates.status !== undefined) { + fields.push(`status = $${paramIndex++}`); + values.push(updates.status); + } + if (updates.fulfilledAt !== undefined) { + fields.push(`fulfilled_at = $${paramIndex++}`); + values.push(updates.fulfilledAt); + } + if (updates.fulfilledSource !== undefined) { + fields.push(`fulfilled_source = $${paramIndex++}`); + values.push(updates.fulfilledSource); + } + if (updates.fulfilledId !== undefined) { + fields.push(`fulfilled_id = $${paramIndex++}`); + values.push(updates.fulfilledId); + } + + fields.push(`updated_at = $${paramIndex++}`); + values.push(Date.now()); + + values.push(requestId); + + await this.db + .prepare(`UPDATE movie_requests SET ${fields.join(', ')} WHERE id = $${paramIndex}`) + .bind(...values) + .run(); + } catch (err) { + console.error('PostgresStorage.updateMovieRequest error:', err); + throw err; + } + } + + async deleteMovieRequest(requestId: string): Promise { + try { + await this.db + .prepare('DELETE FROM movie_requests WHERE id = $1') + .bind(requestId) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteMovieRequest error:', err); + throw err; + } + } + + async getUserMovieRequests(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT request_id FROM user_movie_requests WHERE username = $1') + .bind(userName) + .all(); + + if (!results.results) return []; + return results.results.map((row) => row.request_id as string); + } catch (err) { + console.error('PostgresStorage.getUserMovieRequests error:', err); + return []; + } + } + + async addUserMovieRequest(userName: string, requestId: string): Promise { + try { + await this.db + .prepare('INSERT INTO user_movie_requests (username, request_id) VALUES ($1, $2) ON CONFLICT (username, request_id) DO NOTHING') + .bind(userName, requestId) + .run(); + } catch (err) { + console.error('PostgresStorage.addUserMovieRequest error:', err); + throw err; + } + } + + async removeUserMovieRequest(userName: string, requestId: string): Promise { + try { + await this.db + .prepare('DELETE FROM user_movie_requests WHERE username = $1 AND request_id = $2') + .bind(userName, requestId) + .run(); + } catch (err) { + console.error('PostgresStorage.removeUserMovieRequest error:', err); + throw err; + } + } + + private rowToMovieRequest(row: any): MovieRequest { + return { + id: row.id, + tmdbId: row.tmdb_id || undefined, + title: row.title, + year: row.year || undefined, + mediaType: row.media_type as 'movie' | 'tv', + season: row.season || undefined, + poster: row.poster || undefined, + overview: row.overview || undefined, + requestedBy: JSON.parse(row.requested_by), + requestCount: row.request_count, + status: row.status as 'pending' | 'fulfilled', + createdAt: row.created_at, + updatedAt: row.updated_at, + fulfilledAt: row.fulfilled_at || undefined, + fulfilledSource: row.fulfilled_source || undefined, + fulfilledId: row.fulfilled_id || undefined, + }; + } + + // ==================== 管理员配置和其他 ==================== + + async getAdminConfig(): Promise { + try { + const result = await this.db + .prepare('SELECT config FROM admin_config WHERE id = 1') + .first(); + + if (!result) return null; + return JSON.parse(result.config as string); + } catch (err) { + console.error('PostgresStorage.getAdminConfig error:', err); + return null; + } + } + + async setAdminConfig(config: AdminConfig): Promise { + try { + await this.db + .prepare(` + INSERT INTO admin_config (id, config, updated_at) + VALUES (1, $1, $2) + ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at + `) + .bind(JSON.stringify(config), Date.now()) + .run(); + } catch (err) { + console.error('PostgresStorage.setAdminConfig error:', err); + throw err; + } + } + + async clearAllData(): Promise { + try { + // 清空所有表(保留结构) + const tables = [ + 'play_records', + 'favorites', + 'search_history', + 'skip_configs', + 'danmaku_filter_configs', + 'notifications', + 'movie_requests', + 'user_movie_requests', + 'favorite_check_times', + 'global_config', + ]; + + for (const table of tables) { + await this.db.prepare(`DELETE FROM ${table}`).run(); + } + } catch (err) { + console.error('PostgresStorage.clearAllData error:', err); + throw err; + } + } + + async getGlobalValue(key: string): Promise { + try { + const result = await this.db + .prepare('SELECT value FROM global_config WHERE key = $1') + .bind(key) + .first(); + + return result ? (result.value as string) : null; + } catch (err) { + console.error('PostgresStorage.getGlobalValue error:', err); + return null; + } + } + + async setGlobalValue(key: string, value: string): Promise { + try { + await this.db + .prepare(` + INSERT INTO global_config (key, value, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at + `) + .bind(key, value, Date.now()) + .run(); + } catch (err) { + console.error('PostgresStorage.setGlobalValue error:', err); + throw err; + } + } + + async deleteGlobalValue(key: string): Promise { + try { + await this.db + .prepare('DELETE FROM global_config WHERE key = $1') + .bind(key) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteGlobalValue error:', err); + throw err; + } + } + + async getLastFavoriteCheckTime(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT last_check_time FROM favorite_check_times WHERE username = $1') + .bind(userName) + .first(); + + return (result?.last_check_time as number) || 0; + } catch (err) { + console.error('PostgresStorage.getLastFavoriteCheckTime error:', err); + return 0; + } + } + + async setLastFavoriteCheckTime(userName: string, timestamp: number): Promise { + try { + await this.db + .prepare(` + INSERT INTO favorite_check_times (username, last_check_time) + VALUES ($1, $2) + ON CONFLICT (username) DO UPDATE SET last_check_time = EXCLUDED.last_check_time + `) + .bind(userName, timestamp) + .run(); + } catch (err) { + console.error('PostgresStorage.setLastFavoriteCheckTime error:', err); + throw err; + } + } + + async updateLastMovieRequestTime(userName: string, timestamp: number): Promise { + try { + await this.db + .prepare('UPDATE users SET last_movie_request_time = $1 WHERE username = $2') + .bind(timestamp, userName) + .run(); + } catch (err) { + console.error('PostgresStorage.updateLastMovieRequestTime error:', err); + throw err; + } + } +} + +/** + * Redis Hash 兼容适配器 + * 用于支持设备管理功能(refresh token 存储) + * + * 使用 global_config 表模拟 Redis Hash 操作 + * key 格式:user_tokens:{username}:{tokenId} + */ +class PostgresRedisHashAdapter { + constructor(private db: DatabaseAdapter) {} + + async hSet(hashKey: string, field: string, value: string): Promise { + const key = `${hashKey}:${field}`; + await this.db + .prepare(` + INSERT INTO global_config (key, value, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at + `) + .bind(key, value, Date.now()) + .run(); + } + + async hGet(hashKey: string, field: string): Promise { + const key = `${hashKey}:${field}`; + const result = await this.db + .prepare('SELECT value FROM global_config WHERE key = $1') + .bind(key) + .first(); + + return result ? (result.value as string) : null; + } + + async hGetAll(hashKey: string): Promise> { + const prefix = `${hashKey}:`; + const results = await this.db + .prepare('SELECT key, value FROM global_config WHERE key LIKE $1') + .bind(`${prefix}%`) + .all(); + + const hash: Record = {}; + + if (results && results.results) { + for (const row of results.results) { + const fullKey = row.key as string; + const field = fullKey.substring(prefix.length); + hash[field] = row.value as string; + } + } + + return hash; + } + + async hDel(hashKey: string, field: string): Promise { + const key = `${hashKey}:${field}`; + await this.db + .prepare('DELETE FROM global_config WHERE key = $1') + .bind(key) + .run(); + } + + async del(hashKey: string): Promise { + const prefix = `${hashKey}:`; + await this.db + .prepare('DELETE FROM global_config WHERE key LIKE $1') + .bind(`${prefix}%`) + .run(); + } +}