diff --git a/.gitignore b/.gitignore index 11c736b..1437f13 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,9 @@ public/workbox-*.js public/workbox-*.js.map /.claude -/.vscode \ No newline at end of file +/.vscode +# SQLite 开发数据库 +.data/ +*.db +*.db-shm +*.db-wal diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..baa1be9 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,170 @@ +-- ============================================ +-- MoonTV Plus - Cloudflare D1 数据库结构 +-- 版本: 1.0.0 +-- 创建时间: 2026-01-30 +-- ============================================ + +-- 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 INTEGER NOT NULL, + playrecord_migrated INTEGER DEFAULT 0, + favorite_migrated INTEGER DEFAULT 0, + skip_migrated INTEGER DEFAULT 0, + last_movie_request_time INTEGER, + 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 INTEGER NOT NULL, -- 播放进度(秒) + total_time INTEGER NOT NULL, -- 总时长(秒) + save_time INTEGER 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 INTEGER 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 INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + keyword TEXT NOT NULL, + timestamp INTEGER 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 INTEGER 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 INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + fulfilled_at INTEGER, + 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 INTEGER 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 INTEGER NOT NULL +); + +-- 12. 收藏更新检查时间表 +CREATE TABLE IF NOT EXISTS favorite_check_times ( + username TEXT PRIMARY KEY, + last_check_time INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); diff --git a/next.config.js b/next.config.js index d9d43bc..2b16969 100644 --- a/next.config.js +++ b/next.config.js @@ -35,7 +35,7 @@ const nextConfig = { ], }, - webpack(config) { + webpack(config, { isServer }) { // Grab the existing rule that handles SVG imports const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg') @@ -71,6 +71,21 @@ const nextConfig = { crypto: false, }; + // Exclude better-sqlite3 and D1 modules from client-side bundle + if (!isServer) { + config.externals = config.externals || []; + config.externals.push({ + 'better-sqlite3': 'commonjs better-sqlite3', + }); + + config.resolve.alias = { + ...config.resolve.alias, + 'better-sqlite3': false, + '@/lib/d1.db': false, + '@/lib/d1-adapter': false, + }; + } + return config; }, }; diff --git a/package.json b/package.json index 7894fc0..5272da3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "gen:manifest": "node scripts/generate-manifest.js", "postbuild": "echo 'Build completed - sitemap generation disabled'", "prepare": "husky install", - "watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY" + "watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY", + "init:sqlite": "node scripts/init-sqlite.js", + "db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -36,6 +38,7 @@ "anime4k-webgpu": "^1.0.0", "artplayer": "^5.3.0", "artplayer-plugin-danmuku": "^5.2.0", + "better-sqlite3": "^12.6.2", "bs58": "^6.0.0", "cheerio": "^1.1.2", "clsx": "^2.0.0", @@ -64,6 +67,7 @@ "react-window": "^2.2.3", "redis": "^4.6.7", "remark-gfm": "^3.0.1", + "server-only": "^0.0.1", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "swiper": "^11.2.8", @@ -81,6 +85,7 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^15.0.7", + "@types/better-sqlite3": "^7.6.11", "@types/bs58": "^5.0.0", "@types/he": "^1.2.3", "@types/node": "24.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c7d5d..51714b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: artplayer-plugin-danmuku: specifier: ^5.2.0 version: 5.2.0 + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 bs58: specifier: ^6.0.0 version: 6.0.0 @@ -131,6 +134,9 @@ importers: remark-gfm: specifier: ^3.0.1 version: 3.0.1 + server-only: + specifier: ^0.0.1 + version: 0.0.1 socket.io: specifier: ^4.8.1 version: 4.8.3 @@ -177,6 +183,9 @@ importers: '@testing-library/react': specifier: ^15.0.7 version: 15.0.7(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/better-sqlite3': + specifier: ^7.6.11 + version: 7.6.13 '@types/bs58': specifier: ^5.0.0 version: 5.0.0 @@ -3146,6 +3155,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/bs58@5.0.0': resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==} deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed. @@ -3986,6 +3998,9 @@ packages: base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} @@ -3994,6 +4009,10 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} @@ -4004,6 +4023,9 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -4047,6 +4069,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -4150,6 +4175,9 @@ packages: resolution: {integrity: sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -4499,9 +4527,17 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4979,6 +5015,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect@27.5.1: resolution: {integrity: sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -5124,6 +5164,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -5217,6 +5260,9 @@ packages: engines: {node: '>=10'} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5402,6 +5448,9 @@ packages: idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -6397,6 +6446,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-document@2.19.2: resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} @@ -6463,6 +6516,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6520,6 +6576,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6580,6 +6639,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6961,6 +7024,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} engines: {node: '>= 0.8.0'} @@ -7110,6 +7178,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -7384,6 +7456,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -7441,6 +7516,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -7654,6 +7735,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -7752,6 +7837,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -7944,6 +8036,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.3.2: resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} engines: {node: '>= 0.8.0'} @@ -12807,6 +12902,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.0.3 + '@types/bs58@5.0.0': dependencies: bs58: 6.0.0 @@ -13888,10 +13987,17 @@ snapshots: base-x@5.0.1: {} + base64-js@1.5.1: {} + base64id@2.0.0: {} baseline-browser-mapping@2.9.11: {} + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + big.js@5.2.2: {} binary-extensions@2.3.0: {} @@ -13900,6 +14006,12 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} body-parser@2.2.2: @@ -13955,6 +14067,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtin-modules@3.3.0: {} busboy@1.6.0: @@ -14065,6 +14182,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + chownr@2.0.0: {} chownr@3.0.0: {} @@ -14386,8 +14505,14 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@0.7.0: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -15059,6 +15184,8 @@ snapshots: exit@0.1.2: {} + expand-template@2.0.3: {} + expect@27.5.1: dependencies: '@jest/types': 27.5.1 @@ -15251,6 +15378,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -15350,6 +15479,8 @@ snapshots: split2: 3.2.2 through2: 4.0.2 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -15579,6 +15710,8 @@ snapshots: idb@7.1.1: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -17123,6 +17256,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + min-document@2.19.2: dependencies: dom-walk: 0.1.2 @@ -17191,6 +17326,8 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -17240,6 +17377,8 @@ snapshots: nanoid@5.1.6: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare-lite@1.4.0: {} @@ -17310,6 +17449,10 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.87.0: + dependencies: + semver: 7.7.3 + node-domexception@1.0.0: {} node-fetch@2.6.7: @@ -17649,6 +17792,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.1.2: {} prelude-ls@1.2.1: {} @@ -17742,6 +17900,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -18096,6 +18261,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -18193,6 +18360,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -18454,6 +18629,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strnum@1.1.2: {} @@ -18562,6 +18739,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@6.2.1: dependencies: chownr: 2.0.0 @@ -18760,6 +18952,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.3.2: dependencies: prelude-ls: 1.1.2 diff --git a/scripts/init-sqlite.js b/scripts/init-sqlite.js new file mode 100644 index 0000000..2d1d5ac --- /dev/null +++ b/scripts/init-sqlite.js @@ -0,0 +1,62 @@ +const Database = require('better-sqlite3'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// SHA-256 加密密码(与 Redis 保持一致) +function hashPassword(password) { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +// 确保 .data 目录存在 +const dataDir = path.join(__dirname, '../.data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +// 创建数据库 +const dbPath = path.join(dataDir, 'moontv.db'); +const db = new Database(dbPath); + +console.log('📦 Initializing SQLite database for development...'); +console.log('📍 Database location:', dbPath); + +// 读取迁移脚本 +const migrationPath = path.join(__dirname, '../migrations/001_initial_schema.sql'); +if (!fs.existsSync(migrationPath)) { + console.error('❌ Migration file not found:', migrationPath); + process.exit(1); +} + +const sql = fs.readFileSync(migrationPath, 'utf8'); + +// 执行迁移 +try { + db.exec(sql); + console.log('✅ Database schema created successfully!'); + + // 创建默认管理员用户(可选) + const username = process.env.USERNAME || 'admin'; + const password = process.env.PASSWORD || '123456789'; + const passwordHash = hashPassword(password); + + const stmt = db.prepare(` + INSERT OR IGNORE INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated) + VALUES (?, ?, 'owner', ?, 1, 1, 1) + `); + + stmt.run(username, passwordHash, Date.now()); + console.log(`✅ Default admin user created: ${username}`); +} catch (err) { + console.error('❌ Migration failed:', err); + process.exit(1); +} finally { + db.close(); +} + +console.log(''); +console.log('🎉 SQLite database initialized successfully!'); +console.log(''); +console.log('Next steps:'); +console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=d1 in .env'); +console.log('2. Run: npm run dev'); diff --git a/src/lib/d1-adapter.ts b/src/lib/d1-adapter.ts new file mode 100644 index 0000000..8bda51f --- /dev/null +++ b/src/lib/d1-adapter.ts @@ -0,0 +1,170 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * 统一的数据库适配器接口 + * 兼容 Cloudflare D1 和 better-sqlite3 + * + * 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包 + */ + +// D1 PreparedStatement 接口 +export interface D1PreparedStatement { + bind(...values: any[]): D1PreparedStatement; + first(colName?: string): Promise; + run(): Promise>; + all(): Promise>; +} + +export interface D1Result { + results?: T[]; + success: boolean; + meta?: any; + error?: string; +} + +// 统一的数据库接口 +export interface DatabaseAdapter { + prepare(query: string): D1PreparedStatement; + batch?(statements: D1PreparedStatement[]): Promise; + exec?(query: string): void; +} + +/** + * Cloudflare D1 适配器(生产环境) + */ +export class CloudflareD1Adapter implements DatabaseAdapter { + constructor(private db: D1Database) {} + + prepare(query: string): D1PreparedStatement { + return this.db.prepare(query); + } + + async batch(statements: D1PreparedStatement[]): Promise { + return this.db.batch(statements as any); + } +} + +/** + * SQLite 适配器(开发环境) + * 包装 better-sqlite3 以兼容 D1 API + */ +export class SQLiteAdapter implements DatabaseAdapter { + private db: any; // better-sqlite3 Database + + constructor(db: any) { + this.db = db; + } + + prepare(query: string): D1PreparedStatement { + const stmt = this.db.prepare(query); + return new SQLitePreparedStatement(stmt); + } + + batch(statements: D1PreparedStatement[]): Promise { + // SQLite 使用事务模拟 batch + return new Promise((resolve, reject) => { + try { + const results: D1Result[] = []; + const transaction = this.db.transaction(() => { + for (const stmt of statements) { + const result = (stmt as any).runSync(); + results.push(result); + } + }); + transaction(); + resolve(results); + } catch (err) { + reject(err); + } + }); + } + + exec(query: string): void { + this.db.exec(query); + } +} + +/** + * SQLite PreparedStatement 包装器 + * 将 better-sqlite3 API 转换为 D1 兼容 API + */ +class SQLitePreparedStatement implements D1PreparedStatement { + private stmt: any; + private params: any[] = []; + + constructor(stmt: any) { + this.stmt = stmt; + } + + bind(...values: any[]): D1PreparedStatement { + this.params = values; + return this; + } + + async first(colName?: string): Promise { + try { + const result = this.stmt.get(...this.params); + if (!result) return null; + if (colName) return result[colName] ?? null; + return result; + } catch (err) { + console.error('SQLite first() error:', err); + return null; + } + } + + async run(): Promise> { + try { + const info = this.stmt.run(...this.params); + return { + success: true, + meta: { + changes: info.changes, + last_row_id: info.lastInsertRowid, + }, + }; + } catch (err: any) { + console.error('SQLite run() error:', err); + return { + success: false, + error: err.message, + }; + } + } + + async all(): Promise> { + try { + const results = this.stmt.all(...this.params); + return { + success: true, + results: results || [], + }; + } catch (err: any) { + console.error('SQLite all() error:', err); + return { + success: false, + error: err.message, + results: [], + }; + } + } + + // 同步版本(用于 batch) + runSync(): D1Result { + try { + const info = this.stmt.run(...this.params); + return { + success: true, + meta: { + changes: info.changes, + last_row_id: info.lastInsertRowid, + }, + }; + } catch (err: any) { + return { + success: false, + error: err.message, + }; + } + } +} diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts new file mode 100644 index 0000000..0c806a0 --- /dev/null +++ b/src/lib/d1.db.ts @@ -0,0 +1,1414 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + +/** + * D1 Storage Implementation + * + * 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包 + */ + +import { + IStorage, + PlayRecord, + Favorite, + SkipConfig, + DanmakuFilterConfig, + Notification, + MovieRequest, + AdminConfig, +} from './types'; +import { DatabaseAdapter } from './d1-adapter'; + +/** + * Cloudflare D1 存储实现 + * + * 特点: + * - 开发环境:使用 better-sqlite3(本地 SQLite 文件) + * - 生产环境:使用 Cloudflare D1(云端分布式数据库) + * - 统一接口:通过 DatabaseAdapter 抽象层实现 + * + * 使用方式: + * 1. 设置环境变量:NEXT_PUBLIC_STORAGE_TYPE=d1 + * 2. 开发环境:运行 npm run init:sqlite + * 3. 生产环境:配置 wrangler.toml 并运行迁移 + */ +export class D1Storage implements IStorage { + private db: DatabaseAdapter; + + constructor(adapter: DatabaseAdapter) { + this.db = adapter; + } + + // ==================== 播放记录 ==================== + + async getPlayRecord(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM play_records WHERE username = ? AND key = ?') + .bind(userName, key) + .first(); + + if (!result) return null; + return this.rowToPlayRecord(result); + } catch (err) { + console.error('D1Storage.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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('D1Storage.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 = ? 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('D1Storage.getAllPlayRecords error:', err); + throw err; + } + } + + async deletePlayRecord(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM play_records WHERE username = ? AND key = ?') + .bind(userName, key) + .run(); + } catch (err) { + console.error('D1Storage.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 = ?') + .bind(userName) + .first(); + + const count = (countResult?.count as number) || 0; + if (count <= threshold) return; + + // 删除超出限制的旧记录 + await this.db + .prepare(` + DELETE FROM play_records + WHERE username = ? + AND key NOT IN ( + SELECT key FROM play_records + WHERE username = ? + ORDER BY save_time DESC + LIMIT ? + ) + `) + .bind(userName, userName, maxRecords) + .run(); + + console.log(`D1Storage: Cleaned up old play records for user ${userName}`); + } catch (err) { + console.error('D1Storage.cleanupOldPlayRecords error:', err); + throw err; + } + } + + async migratePlayRecords(userName: string): Promise { + // D1 是新系统,不需要迁移 + // 只需标记为已迁移 + try { + await this.db + .prepare('UPDATE users SET playrecord_migrated = 1 WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.migratePlayRecords error:', err); + } + } + + // ==================== 收藏 ==================== + + async getFavorite(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM favorites WHERE username = ? AND key = ?') + .bind(userName, key) + .first(); + + if (!result) return null; + return this.rowToFavorite(result); + } catch (err) { + console.error('D1Storage.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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('D1Storage.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 = ? 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('D1Storage.getAllFavorites error:', err); + throw err; + } + } + + async deleteFavorite(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM favorites WHERE username = ? AND key = ?') + .bind(userName, key) + .run(); + } catch (err) { + console.error('D1Storage.deleteFavorite error:', err); + throw err; + } + } + + async migrateFavorites(userName: string): Promise { + // D1 是新系统,不需要迁移 + try { + await this.db + .prepare('UPDATE users SET favorite_migrated = 1 WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.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, + }; + } + + // ==================== 用户管理 ==================== + + // SHA256 加密密码(与 Redis 保持一致) + 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 = ? 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('D1Storage.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 = ? LIMIT 1') + .bind(userName) + .first(); + + return result !== null; + } catch (err) { + console.error('D1Storage.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 = ? WHERE username = ?') + .bind(passwordHash, userName) + .run(); + } catch (err) { + console.error('D1Storage.changePassword error:', err); + throw err; + } + } + + async deleteUser(userName: string): Promise { + try { + // 由于设置了 ON DELETE CASCADE,删除用户会自动删除相关数据 + await this.db + .prepare('DELETE FROM users WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.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('D1Storage.getAllUsers error:', err); + return []; + } + } + + async getUserInfoV2(userName: string): Promise { + try { + // 先尝试从数据库获取用户信息 + const user = await this.db + .prepare('SELECT * FROM users WHERE username = ?') + .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('D1Storage.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 (?, ?, ?, 0, ?, ?, ?, ?, 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('D1Storage.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 ? OFFSET ? + `) + .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('D1Storage.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 = ?') + .bind(userName) + .first(); + + if (!user) return false; + + const hashedPassword = await this.hashPassword(password); + return user.password_hash === hashedPassword; + } catch (err) { + console.error('D1Storage.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[] = []; + + if (updates.role !== undefined) { + fields.push('role = ?'); + values.push(updates.role); + } + if (updates.banned !== undefined) { + fields.push('banned = ?'); + values.push(updates.banned ? 1 : 0); + } + if (updates.tags !== undefined) { + fields.push('tags = ?'); + values.push(JSON.stringify(updates.tags)); + } + if (updates.oidcSub !== undefined) { + fields.push('oidc_sub = ?'); + values.push(updates.oidcSub); + } + if (updates.enabledApis !== undefined) { + fields.push('enabled_apis = ?'); + 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 = ?`) + .bind(...values) + .run(); + } catch (err) { + console.error('D1Storage.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 = ? WHERE username = ?') + .bind(passwordHash, userName) + .run(); + } catch (err) { + console.error('D1Storage.changePasswordV2 error:', err); + throw err; + } + } + + async checkUserExistV2(userName: string): Promise { + try { + const user = await this.db + .prepare('SELECT 1 FROM users WHERE username = ?') + .bind(userName) + .first(); + + return !!user; + } catch (err) { + console.error('D1Storage.checkUserExistV2 error:', err); + return false; + } + } + + async getUserByOidcSub(oidcSub: string): Promise { + try { + const user = await this.db + .prepare('SELECT username FROM users WHERE oidc_sub = ?') + .bind(oidcSub) + .first(); + + return user ? (user.username as string) : null; + } catch (err) { + console.error('D1Storage.getUserByOidcSub error:', err); + return null; + } + } + + async deleteUserV2(userName: string): Promise { + try { + // D1 的外键约束会自动级联删除相关数据 + await this.db + .prepare('DELETE FROM users WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.deleteUserV2 error:', err); + throw err; + } + } + + async getUsersByTag(tagName: string): Promise { + try { + // SQLite 不支持 JSON 查询,需要使用 LIKE + const result = await this.db + .prepare(` + SELECT username FROM users + WHERE tags LIKE ? + `) + .bind(`%"${tagName}"%`) + .all(); + + if (!result.results) return []; + + return result.results.map((row: any) => row.username as string); + } catch (err) { + console.error('D1Storage.getUsersByTag error:', err); + return []; + } + } + + async getUserEmail?(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT email FROM users WHERE username = ?') + .bind(userName) + .first(); + + return result?.email as string | null; + } catch (err) { + console.error('D1Storage.getUserEmail error:', err); + return null; + } + } + + async setUserEmail?(userName: string, email: string): Promise { + try { + await this.db + .prepare('UPDATE users SET email = ? WHERE username = ?') + .bind(email, userName) + .run(); + } catch (err) { + console.error('D1Storage.setUserEmail error:', err); + throw err; + } + } + + async getEmailNotificationPreference?(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT email_notifications FROM users WHERE username = ?') + .bind(userName) + .first(); + + return result?.email_notifications === 1; + } catch (err) { + console.error('D1Storage.getEmailNotificationPreference error:', err); + return true; // 默认开启 + } + } + + async setEmailNotificationPreference?(userName: string, enabled: boolean): Promise { + try { + await this.db + .prepare('UPDATE users SET email_notifications = ? WHERE username = ?') + .bind(enabled ? 1 : 0, userName) + .run(); + } catch (err) { + console.error('D1Storage.setEmailNotificationPreference error:', err); + throw err; + } + } + + // ==================== 搜索历史 ==================== + + async getSearchHistory(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT keyword FROM search_history WHERE username = ? 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('D1Storage.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 (?, ?, ?) + 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 = ?') + .bind(userName) + .first(); + + const count = (countResult?.count as number) || 0; + if (count > 20) { + await this.db + .prepare(` + DELETE FROM search_history + WHERE username = ? + AND id NOT IN ( + SELECT id FROM search_history + WHERE username = ? + ORDER BY timestamp DESC + LIMIT 20 + ) + `) + .bind(userName, userName) + .run(); + } + } catch (err) { + console.error('D1Storage.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 = ? AND keyword = ?') + .bind(userName, keyword) + .run(); + } else { + await this.db + .prepare('DELETE FROM search_history WHERE username = ?') + .bind(userName) + .run(); + } + } catch (err) { + console.error('D1Storage.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 = ? AND key = ?') + .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('D1Storage.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 (?, ?, ?, ?, ?) + 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('D1Storage.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 = ? AND key = ?') + .bind(userName, key) + .run(); + } catch (err) { + console.error('D1Storage.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 = ?') + .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('D1Storage.getAllSkipConfigs error:', err); + return {}; + } + } + + async migrateSkipConfigs(userName: string): Promise { + try { + await this.db + .prepare('UPDATE users SET skip_migrated = 1 WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.migrateSkipConfigs error:', err); + } + } + + // ==================== 弹幕过滤配置 ==================== + + async getDanmakuFilterConfig(userName: string): Promise { + try { + const result = await this.db + .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = ?') + .bind(userName) + .first(); + + if (!result) return null; + return JSON.parse(result.rules as string); + } catch (err) { + console.error('D1Storage.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 (?, ?) + ON CONFLICT(username) DO UPDATE SET rules = excluded.rules + `) + .bind(userName, JSON.stringify(config)) + .run(); + } catch (err) { + console.error('D1Storage.setDanmakuFilterConfig error:', err); + throw err; + } + } + + async deleteDanmakuFilterConfig(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM danmaku_filter_configs WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.deleteDanmakuFilterConfig error:', err); + throw err; + } + } + + // ==================== 通知 ==================== + + async getNotifications(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM notifications WHERE username = ? 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('D1Storage.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 (?, ?, ?, ?, ?, ?, ?, ?) + `) + .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('D1Storage.addNotification error:', err); + throw err; + } + } + + async markNotificationAsRead(userName: string, notificationId: string): Promise { + try { + await this.db + .prepare('UPDATE notifications SET read = 1 WHERE username = ? AND id = ?') + .bind(userName, notificationId) + .run(); + } catch (err) { + console.error('D1Storage.markNotificationAsRead error:', err); + throw err; + } + } + + async deleteNotification(userName: string, notificationId: string): Promise { + try { + await this.db + .prepare('DELETE FROM notifications WHERE username = ? AND id = ?') + .bind(userName, notificationId) + .run(); + } catch (err) { + console.error('D1Storage.deleteNotification error:', err); + throw err; + } + } + + async clearAllNotifications(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM notifications WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.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 = ? AND read = 0') + .bind(userName) + .first(); + + return (result?.count as number) || 0; + } catch (err) { + console.error('D1Storage.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('D1Storage.getAllMovieRequests error:', err); + return []; + } + } + + async getMovieRequest(requestId: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM movie_requests WHERE id = ?') + .bind(requestId) + .first(); + + if (!result) return null; + return this.rowToMovieRequest(result); + } catch (err) { + console.error('D1Storage.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .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('D1Storage.createMovieRequest error:', err); + throw err; + } + } + + async updateMovieRequest(requestId: string, updates: Partial): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + + if (updates.requestedBy !== undefined) { + fields.push('requested_by = ?'); + values.push(JSON.stringify(updates.requestedBy)); + } + if (updates.requestCount !== undefined) { + fields.push('request_count = ?'); + values.push(updates.requestCount); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + if (updates.fulfilledAt !== undefined) { + fields.push('fulfilled_at = ?'); + values.push(updates.fulfilledAt); + } + if (updates.fulfilledSource !== undefined) { + fields.push('fulfilled_source = ?'); + values.push(updates.fulfilledSource); + } + if (updates.fulfilledId !== undefined) { + fields.push('fulfilled_id = ?'); + values.push(updates.fulfilledId); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + + values.push(requestId); + + await this.db + .prepare(`UPDATE movie_requests SET ${fields.join(', ')} WHERE id = ?`) + .bind(...values) + .run(); + } catch (err) { + console.error('D1Storage.updateMovieRequest error:', err); + throw err; + } + } + + async deleteMovieRequest(requestId: string): Promise { + try { + await this.db + .prepare('DELETE FROM movie_requests WHERE id = ?') + .bind(requestId) + .run(); + } catch (err) { + console.error('D1Storage.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 = ?') + .bind(userName) + .all(); + + if (!results.results) return []; + return results.results.map((row) => row.request_id as string); + } catch (err) { + console.error('D1Storage.getUserMovieRequests error:', err); + return []; + } + } + + async addUserMovieRequest(userName: string, requestId: string): Promise { + try { + await this.db + .prepare('INSERT OR IGNORE INTO user_movie_requests (username, request_id) VALUES (?, ?)') + .bind(userName, requestId) + .run(); + } catch (err) { + console.error('D1Storage.addUserMovieRequest error:', err); + throw err; + } + } + + async removeUserMovieRequest(userName: string, requestId: string): Promise { + try { + await this.db + .prepare('DELETE FROM user_movie_requests WHERE username = ? AND request_id = ?') + .bind(userName, requestId) + .run(); + } catch (err) { + console.error('D1Storage.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('D1Storage.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, ?, ?) + 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('D1Storage.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('D1Storage.clearAllData error:', err); + throw err; + } + } + + async getGlobalValue(key: string): Promise { + try { + const result = await this.db + .prepare('SELECT value FROM global_config WHERE key = ?') + .bind(key) + .first(); + + return result ? (result.value as string) : null; + } catch (err) { + console.error('D1Storage.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 (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .bind(key, value, Date.now()) + .run(); + } catch (err) { + console.error('D1Storage.setGlobalValue error:', err); + throw err; + } + } + + async deleteGlobalValue(key: string): Promise { + try { + await this.db + .prepare('DELETE FROM global_config WHERE key = ?') + .bind(key) + .run(); + } catch (err) { + console.error('D1Storage.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 = ?') + .bind(userName) + .first(); + + return (result?.last_check_time as number) || 0; + } catch (err) { + console.error('D1Storage.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 (?, ?) + ON CONFLICT(username) DO UPDATE SET last_check_time = excluded.last_check_time + `) + .bind(userName, timestamp) + .run(); + } catch (err) { + console.error('D1Storage.setLastFavoriteCheckTime error:', err); + throw err; + } + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index b735e29..7735ef7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -6,13 +6,14 @@ import { RedisStorage } from './redis.db'; import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { UpstashRedisStorage } from './upstash.db'; -// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage' +// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1',默认 'localstorage' const STORAGE_TYPE = (process.env.NEXT_PUBLIC_STORAGE_TYPE as | 'localstorage' | 'redis' | 'upstash' | 'kvrocks' + | 'd1' | undefined) || 'localstorage'; // 创建存储实例 @@ -24,12 +25,67 @@ function createStorage(): IStorage { return new UpstashRedisStorage(); case 'kvrocks': return new KvrocksStorage(); + case 'd1': + // D1Storage 只能在服务端使用,客户端会报错 + if (typeof window !== 'undefined') { + throw new Error('D1Storage can only be used on the server side'); + } + const adapter = getD1Adapter(); + // 动态导入 D1Storage 以避免客户端打包 + const { D1Storage } = require('./d1.db'); + return new D1Storage(adapter); case 'localstorage': default: return null as unknown as IStorage; } } +/** + * 获取 D1 适配器 + * 开发环境:使用 better-sqlite3 + * 生产环境:使用 Cloudflare D1 + */ +function getD1Adapter(): any { + // 动态导入适配器以避免客户端打包 + const { CloudflareD1Adapter, SQLiteAdapter } = require('./d1-adapter'); + + // 生产环境:Cloudflare Workers/Pages + if (typeof process !== 'undefined' && (process as any).env?.DB) { + console.log('Using Cloudflare D1 database'); + return new CloudflareD1Adapter((process as any).env.DB); + } + + // 开发环境:better-sqlite3 + if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { + try { + const Database = require('better-sqlite3'); + const path = require('path'); + const fs = require('fs'); + + const dbPath = path.join(process.cwd(), '.data', 'moontv.db'); + + // 检查数据库文件是否存在 + if (!fs.existsSync(dbPath)) { + console.error('❌ SQLite database not found. Please run: npm run init:sqlite'); + throw new Error('SQLite database not initialized'); + } + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能 + + console.log('Using SQLite database (development mode)'); + console.log('Database location:', dbPath); + + return new SQLiteAdapter(db); + } catch (err) { + console.error('Failed to initialize SQLite:', err); + throw err; + } + } + + throw new Error('D1 database not available. Set NEXT_PUBLIC_STORAGE_TYPE to another option or configure D1.'); +} + // 单例存储实例 let storageInstance: IStorage | null = null; diff --git a/src/lib/refresh-token.ts b/src/lib/refresh-token.ts index ada24ca..bd4a400 100644 --- a/src/lib/refresh-token.ts +++ b/src/lib/refresh-token.ts @@ -1,5 +1,10 @@ /* eslint-disable no-console */ +import { TOKEN_CONFIG } from './token-config'; + +// Re-export TOKEN_CONFIG for backward compatibility +export { TOKEN_CONFIG }; + // Lazy import to avoid Edge Runtime issues in middleware let getStorage: (() => any) | null = null; @@ -11,13 +16,6 @@ async function loadStorage() { return getStorage(); } -// Token 配置 -export const TOKEN_CONFIG = { - ACCESS_TOKEN_AGE: 4 * 60 * 60 * 1000, // 4 小时 - REFRESH_TOKEN_AGE: 60 * 24 * 60 * 60 * 1000, // 60 天 - RENEWAL_THRESHOLD: 10 * 60 * 1000, // 剩余 10 分钟时自动续期 -}; - interface TokenData { token: string; deviceInfo: string; diff --git a/src/lib/token-config.ts b/src/lib/token-config.ts new file mode 100644 index 0000000..9f0a285 --- /dev/null +++ b/src/lib/token-config.ts @@ -0,0 +1,8 @@ +// Token 配置常量 +// 这个文件不依赖任何服务端模块,可以在客户端安全使用 + +export const TOKEN_CONFIG = { + ACCESS_TOKEN_AGE: 4 * 60 * 60 * 1000, // 4 小时 + REFRESH_TOKEN_AGE: 60 * 24 * 60 * 60 * 1000, // 60 天 + RENEWAL_THRESHOLD: 10 * 60 * 1000, // 剩余 10 分钟时自动续期 +};