This commit is contained in:
雅丶涵
2026-07-12 05:06:29 +00:00
committed by GitHub
21 changed files with 980 additions and 40 deletions
+12 -1
View File
@@ -6,7 +6,18 @@ BUILD_TARGET=edgeone
EDGEONE_PAGES=1
# 默认不设置 NEXT_PUBLIC_STORAGE_TYPE,项目会使用默认 localstorage。
# 如需云端持久化,再自行改为 upstash / redis / postgres 等项目支持的存储。
# 如需云端持久化,可选用以下存储方案:
#
# 方案一:Turso(推荐,免费 SQLite 云数据库)
# 1. 注册 https://turso.tech 获取免费数据库
# 2. 设置 NEXT_PUBLIC_STORAGE_TYPE=turso
# 3. 运行 pnpm init:turso 初始化数据库表结构
# 4. 配置 TURSO_URL 和 TURSO_TOKEN
#
# 方案二:Upstash RedisKV 存储)
# 1. 注册 https://upstash.com 获取免费 Redis
# 2. 设置 NEXT_PUBLIC_STORAGE_TYPE=upstash
# 3. 配置 UPSTASH_URL 和 UPSTASH_TOKEN
# 管理员账号(请替换)
USERNAME=admin
+4 -2
View File
@@ -82,6 +82,8 @@ jobs:
PASSWORD: ${{ secrets.PASSWORD }}
UPSTASH_URL: ${{ secrets.UPSTASH_URL }}
UPSTASH_TOKEN: ${{ secrets.UPSTASH_TOKEN }}
TURSO_URL: ${{ secrets.TURSO_URL }}
TURSO_TOKEN: ${{ secrets.TURSO_TOKEN }}
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TMDB_IMAGE_DOMAIN: ${{ secrets.TMDB_IMAGE_DOMAIN }}
TMDB_PROXY: ${{ secrets.TMDB_PROXY }}
@@ -135,7 +137,7 @@ jobs:
NEXT_PUBLIC_STORAGE_PREFIX NEXT_PUBLIC_ENABLE_STREAM_PROXY NEXT_PUBLIC_STREAM_PROXY_URL
NEXT_PUBLIC_ENABLE_TURNSTILE NEXT_PUBLIC_TURNSTILE_SITE_KEY
NEXT_PUBLIC_THEME NEXT_PUBLIC_THEME_COLOR NEXT_PUBLIC_APP_URL NEXT_PUBLIC_BASE_PATH
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN TURSO_URL TURSO_TOKEN
TMDB_API_KEY TMDB_IMAGE_DOMAIN TMDB_PROXY TMDB_REVERSE_PROXY
DANMAKU_API_BASE DANMAKU_API_TOKEN ANNOUNCEMENT CRON_PASSWORD SITE_BASE
REDIS_URL KVROCKS_URL INIT_CONFIG CONFIG_SUBSCRIPTION_URL ENABLE_REGISTER
@@ -195,7 +197,7 @@ jobs:
NEXT_PUBLIC_STORAGE_PREFIX NEXT_PUBLIC_ENABLE_STREAM_PROXY NEXT_PUBLIC_STREAM_PROXY_URL
NEXT_PUBLIC_ENABLE_TURNSTILE NEXT_PUBLIC_TURNSTILE_SITE_KEY
NEXT_PUBLIC_THEME NEXT_PUBLIC_THEME_COLOR NEXT_PUBLIC_APP_URL NEXT_PUBLIC_BASE_PATH
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN TURSO_URL TURSO_TOKEN
TMDB_API_KEY TMDB_IMAGE_DOMAIN TMDB_PROXY TMDB_REVERSE_PROXY
DANMAKU_API_BASE DANMAKU_API_TOKEN ANNOUNCEMENT CRON_PASSWORD SITE_BASE
REDIS_URL KVROCKS_URL INIT_CONFIG CONFIG_SUBSCRIPTION_URL ENABLE_REGISTER
+19 -3
View File
@@ -204,7 +204,7 @@ EdgeOne Pages/Makers 支持通过 API Token 在 GitHub Actions 中自动构建
1. 一个腾讯云 EdgeOne 账号
2. Fork 本项目到你的 GitHub 账号
3. 准备一个 Upstash Redis 实例(推荐)
3. 准备一个 Turso 数据库实例(推荐,免费 SQLite 云数据库)或 Upstash Redis 实例
4. 准备 EdgeOne API Token
#### 配置步骤
@@ -224,7 +224,21 @@ EdgeOne Pages/Makers 支持通过 API Token 在 GitHub Actions 中自动构建
| `EDGEONE_API_TOKEN` | EdgeOne API Token | `your_edgeone_token` |
| `USERNAME` | 站长账号 | `admin` |
| `PASSWORD` | 站长密码 | `your_secure_password` |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `upstash` |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `turso``upstash` |
**使用 Turso(推荐):**
在 [Turso](https://turso.tech/) 注册账号并创建数据库,然后运行 `pnpm init:turso` 初始化表结构(需设置 TURSO_URL 和 TURSO_TOKEN 环境变量)。
| Secret 名称 | 说明 | 示例值 |
| -------------------------- | ------------------- | --------------------------------- |
| `TURSO_URL` | Turso 数据库 URL | `libsql://your-db.turso.io` |
| `TURSO_TOKEN` | Turso 访问令牌 | `your_turso_token` |
**使用 Upstash Redis**
| Secret 名称 | 说明 | 示例值 |
| -------------------------- | ------------------- | ------------------------ |
| `UPSTASH_URL` | Upstash Redis URL | `https://xxx.upstash.io` |
| `UPSTASH_TOKEN` | Upstash Redis Token | `your_upstash_token` |
@@ -458,11 +472,13 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| ANNOUNCEMENT_DISPLAY_MODE | 公告显示模式 | once、every | once |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1 | 无默认,必填字段 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1、turso、postgres | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| TURSO_URL | Turso (libSQL) 数据库连接 url | libsql://xxx.turso.io | 空 |
| TURSO_TOKEN | Turso (libSQL) 数据库访问令牌 | 访问令牌 | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
+6 -1
View File
@@ -104,6 +104,9 @@ const createNextConfig = (phase) => {
'redis',
'@vercel/postgres',
'pg',
'libsql',
'@libsql/isomorphic-fetch',
'@libsql/isomorphic-ws',
].map((pkg) => [
pkg,
path.resolve(
@@ -136,13 +139,14 @@ const createNextConfig = (phase) => {
});
}
// Exclude better-sqlite3, D1, and Postgres modules from client-side bundle
// Exclude better-sqlite3, D1, Postgres, and Turso 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',
'@libsql/client': 'commonjs @libsql/client',
});
config.resolve.alias = {
@@ -152,6 +156,7 @@ const createNextConfig = (phase) => {
'@/lib/d1-adapter': false,
'@/lib/postgres.db': false,
'@/lib/postgres-adapter': false,
'@/lib/turso-adapter': false,
};
}
+2
View File
@@ -22,6 +22,7 @@
"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",
"init:turso": "node scripts/init-turso.js",
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js",
"build:edgeone": "pnpm edgeone:build",
"preview:edgeone": "BUILD_TARGET=edgeone EDGEONE_PAGES=1 node scripts/edgeone-local-preview.mjs",
@@ -61,6 +62,7 @@
"he": "^1.2.0",
"hls.js": "^1.6.10",
"https-proxy-agent": "^7.0.6",
"@libsql/client": "^0.15.0",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"mux.js": "^6.3.0",
+185 -19
View File
@@ -26,6 +26,9 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0([email protected])
'@libsql/client':
specifier: ^0.15.0
version: 0.15.15([email protected])
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
@@ -2566,6 +2569,67 @@ packages:
'@jspm/[email protected]':
resolution: {integrity: sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==}
'@libsql/[email protected]':
resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==}
'@libsql/[email protected]':
resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==}
'@libsql/[email protected]':
resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
cpu: [arm64]
os: [darwin]
'@libsql/[email protected]':
resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
cpu: [x64]
os: [darwin]
'@libsql/[email protected]':
resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==}
'@libsql/[email protected]':
resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==}
engines: {node: '>=18.0.0'}
'@libsql/[email protected]':
resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
'@libsql/[email protected]':
resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
cpu: [arm]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
cpu: [arm]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
cpu: [arm64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
cpu: [arm64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
cpu: [x64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
cpu: [x64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
cpu: [x64]
os: [win32]
'@mapbox/[email protected]':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -2580,6 +2644,9 @@ packages:
'@napi-rs/[email protected]':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@neon-rs/[email protected]':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@neondatabase/[email protected]':
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
@@ -3817,6 +3884,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/[email protected]':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/[email protected]':
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
@@ -5485,6 +5555,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
[email protected]:
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -7180,6 +7254,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
[email protected]:
resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==}
[email protected]:
resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
engines: {node: '>= 0.8'}
@@ -7333,6 +7410,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==}
[email protected]:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32]
[email protected]:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -8606,6 +8688,9 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
[email protected]:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
[email protected]:
resolution: {integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==}
@@ -9691,10 +9776,6 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
engines: {node: '>=20'}
@@ -13716,6 +13797,68 @@ snapshots:
'@jspm/[email protected]': {}
'@libsql/[email protected]([email protected])':
dependencies:
'@libsql/core': 0.15.15
'@libsql/hrana-client': 0.7.0([email protected])
js-base64: 3.8.1
libsql: 0.5.29
promise-limit: 2.7.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]':
dependencies:
js-base64: 3.8.1
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]([email protected])':
dependencies:
'@libsql/isomorphic-fetch': 0.3.1
'@libsql/isomorphic-ws': 0.1.5([email protected])
js-base64: 3.8.1
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]': {}
'@libsql/[email protected]([email protected])':
dependencies:
'@types/ws': 8.18.1
ws: 8.21.0([email protected])
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@mapbox/[email protected]':
dependencies:
consola: 3.4.2
@@ -13745,6 +13888,8 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@neon-rs/[email protected]': {}
'@neondatabase/[email protected]':
dependencies:
'@types/pg': 8.11.6
@@ -15139,11 +15284,11 @@ snapshots:
'@types/[email protected]':
dependencies:
'@types/eslint': 9.6.1
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/[email protected]':
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/[email protected]':
@@ -15285,6 +15430,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 24.0.3
'@types/[email protected]':
dependencies:
'@types/node': 24.0.3
@@ -16053,9 +16202,9 @@ snapshots:
dependencies:
acorn: 8.15.0
[email protected]([email protected]5.0):
[email protected]([email protected]7.0):
dependencies:
acorn: 8.15.0
acorn: 8.17.0
[email protected]([email protected]):
dependencies:
@@ -16958,7 +17107,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.7.3
semver: 7.8.4
uint8array-extras: 1.5.0
[email protected]:
@@ -17226,6 +17375,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -17354,7 +17505,7 @@ snapshots:
[email protected]:
dependencies:
type-fest: 5.3.1
type-fest: 5.7.0
[email protected]:
dependencies:
@@ -18979,7 +19130,7 @@ snapshots:
[email protected]:
dependencies:
get-east-asian-width: 1.4.0
get-east-asian-width: 1.6.0
[email protected]: {}
@@ -19620,6 +19771,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -19774,6 +19927,21 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@neon-rs/load': 0.0.4
detect-libc: 2.0.2
optionalDependencies:
'@libsql/darwin-arm64': 0.5.29
'@libsql/darwin-x64': 0.5.29
'@libsql/linux-arm-gnueabihf': 0.5.29
'@libsql/linux-arm-musleabihf': 0.5.29
'@libsql/linux-arm64-gnu': 0.5.29
'@libsql/linux-arm64-musl': 0.5.29
'@libsql/linux-x64-gnu': 0.5.29
'@libsql/linux-x64-musl': 0.5.29
'@libsql/win32-x64-msvc': 0.5.29
[email protected]: {}
[email protected]: {}
@@ -20996,6 +21164,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -22258,7 +22428,7 @@ snapshots:
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 24.0.3
acorn: 8.15.0
acorn: 8.17.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
@@ -22322,10 +22492,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
tagged-tag: 1.0.0
[email protected]:
dependencies:
tagged-tag: 1.0.0
@@ -22805,13 +22971,13 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4([email protected]5.0)
acorn: 8.17.0
acorn-import-phases: 1.0.4([email protected]7.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.4
+2
View File
@@ -42,6 +42,8 @@ const runtimeEnvKeys = [
'NEXT_PUBLIC_STORAGE_TYPE',
'UPSTASH_URL',
'UPSTASH_TOKEN',
'TURSO_URL',
'TURSO_TOKEN',
'TMDB_API_KEY',
'TMDB_IMAGE_DOMAIN',
'NEXT_PUBLIC_SITE_NAME',
+205
View File
@@ -0,0 +1,205 @@
/**
* Turso (libSQL) 数据库初始化脚本
*
* 创建数据库表结构并初始化默认管理员用户
* 复用 SQLite 迁移文件(Turso 基于 libSQL,完全兼容 SQLite 语法)
*
* 使用方式:
* 1. 安装依赖:pnpm install
* 2. 设置环境变量:
* export TURSO_URL=libsql://your-database.turso.io
* export TURSO_TOKEN=your-auth-token
* export USERNAME=admin
* export PASSWORD=your-password
* 3. 运行:node scripts/init-turso.js
*/
const { createClient } = require('@libsql/client/http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
function splitSqlStatements(sql) {
const withoutLineComments = sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
return withoutLineComments
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function getMigrationFiles() {
if (!fs.existsSync(MIGRATIONS_DIR)) {
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
}
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((file) => file.endsWith('.sql'))
.sort((a, b) => a.localeCompare(b));
}
function isIgnorableMigrationError(error) {
const message =
error instanceof Error ? error.message : String(error || '');
return (
(message.includes('table') && message.includes('already exists')) ||
(message.includes('index') && message.includes('already exists')) ||
message.includes('duplicate column name')
);
}
async function tableExists(client, tableName) {
const result = await client.execute({
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
args: [tableName],
});
return result.rows.length > 0;
}
async function ensureMigrationTable(client) {
await client.execute(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`);
}
async function getAppliedMigrations(client) {
const result = await client.execute('SELECT filename FROM schema_migrations');
return new Set(result.rows.map((row) => row.filename));
}
async function markMigrationApplied(client, filename) {
await client.execute({
sql: 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)',
args: [filename, Date.now()],
});
}
async function seedExistingMigrationBaseline(
client,
migrationFiles,
hadExistingSchema
) {
const applied = await getAppliedMigrations(client);
if (!hadExistingSchema || applied.size > 0) return;
for (const file of migrationFiles) {
if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
await markMigrationApplied(client, file);
}
}
}
async function runMigrations(client) {
const migrationFiles = getMigrationFiles();
const hadExistingSchema = await tableExists(client, 'users');
await ensureMigrationTable(client);
await seedExistingMigrationBaseline(client, migrationFiles, hadExistingSchema);
for (const file of migrationFiles) {
const applied = await getAppliedMigrations(client);
if (applied.has(file)) {
console.log(`⏭️ Migration already applied: ${file}`);
continue;
}
const migrationPath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(migrationPath, 'utf8');
const statements = splitSqlStatements(sql);
console.log(`▶️ Applying migration: ${file}`);
for (const statement of statements) {
try {
await client.execute(statement);
} catch (error) {
if (isIgnorableMigrationError(error)) {
console.log(`⏭️ Statement skipped in ${file}: ${error.message}`);
continue;
}
throw error;
}
}
await markMigrationApplied(client, file);
console.log(`✅ Migration applied: ${file}`);
}
}
async function ensureDefaultAdmin(client) {
const username = process.env.USERNAME || 'admin';
const password = process.env.PASSWORD || '123456789';
const passwordHash = hashPassword(password);
const existingUser = await client.execute({
sql: 'SELECT username FROM users WHERE username = ? LIMIT 1',
args: [username],
});
if (existingUser.rows.length > 0) {
console.log(`️ Admin user already exists: ${username}`);
return;
}
await client.execute({
sql: `INSERT INTO users (
username, password_hash, role, created_at,
playrecord_migrated, favorite_migrated, skip_migrated
) VALUES (?, ?, 'owner', ?, 1, 1, 1)`,
args: [username, passwordHash, Date.now()],
});
console.log(`✅ Default admin user created: ${username}`);
}
async function initTursoDatabase() {
const tursoUrl = process.env.TURSO_URL;
const tursoToken = process.env.TURSO_TOKEN;
if (!tursoUrl || !tursoToken) {
console.error(
'❌ TURSO_URL and TURSO_TOKEN environment variables must be set'
);
console.error('');
console.error('Example:');
console.error(' export TURSO_URL=libsql://your-database.turso.io');
console.error(' export TURSO_TOKEN=your-auth-token');
process.exit(1);
}
const client = createClient({
url: tursoUrl,
authToken: tursoToken,
});
console.log('📦 Initializing Turso (libSQL) database...');
console.log('🔗 Database URL:', tursoUrl);
try {
await runMigrations(client);
await ensureDefaultAdmin(client);
console.log('');
console.log('🎉 Turso database is ready!');
console.log('');
console.log('Next steps:');
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=turso in your environment');
console.log('2. Set TURSO_URL and TURSO_TOKEN environment variables');
console.log('3. Deploy your application');
} catch (err) {
console.error('❌ Turso initialization failed:', err);
process.exit(1);
}
}
initTursoDatabase();
+199
View File
@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
BarChart3,
BookMarked,
BookOpen,
Bot,
@@ -406,6 +407,11 @@ interface SiteConfig {
OIDCClientId?: string;
OIDCClientSecret?: string;
OIDCButtonText?: string;
AnalyticsEnabled?: boolean;
AnalyticsProvider?: 'umami' | 'google' | 'clarity' | 'custom';
AnalyticsScriptUrl?: string;
AnalyticsWebsiteId?: string;
AnalyticsCustomScript?: string;
}
// 视频源数据类型
@@ -10198,6 +10204,11 @@ const SiteConfigComponent = ({
OIDCClientId: '',
OIDCClientSecret: '',
OIDCButtonText: '',
AnalyticsEnabled: false,
AnalyticsProvider: 'umami',
AnalyticsScriptUrl: '',
AnalyticsWebsiteId: '',
AnalyticsCustomScript: '',
});
// 豆瓣数据源相关状态
@@ -11528,6 +11539,194 @@ const SiteConfigComponent = ({
</div>
</details>
{/* 流量统计配置 */}
<details className='group rounded-lg border border-gray-200 p-4 dark:border-gray-700'>
<summary className='flex cursor-pointer items-center justify-between font-medium text-gray-900 dark:text-gray-100'>
<span className='flex items-center gap-2'>
<BarChart3 className='h-5 w-5' />
</span>
<ChevronDown className='h-5 w-5 transition-transform group-open:rotate-180' />
</summary>
<div className='mt-4 space-y-4'>
{/* 启用开关 */}
<div className='flex items-center justify-between'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
UmamiGoogle Analytics
</p>
</div>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
AnalyticsEnabled: !prev.AnalyticsEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.AnalyticsEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.AnalyticsEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
{siteSettings.AnalyticsEnabled && (
<>
{/* 统计服务提供商 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<select
value={siteSettings.AnalyticsProvider}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsProvider: e.target.value as 'umami' | 'google' | 'clarity' | 'custom',
}))
}
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
>
<option value='umami'>Umami</option>
<option value='google'>Google Analytics</option>
<option value='clarity'>Microsoft Clarity+</option>
<option value='custom'></option>
</select>
</div>
{siteSettings.AnalyticsProvider === 'umami' && (
<>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Umami
</label>
<input
type='text'
value={siteSettings.AnalyticsScriptUrl}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsScriptUrl: e.target.value,
}))
}
placeholder='https://your-umami-server.com/script.js'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Umami script.js URL
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
ID (Website ID)
</label>
<input
type='text'
value={siteSettings.AnalyticsWebsiteId}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsWebsiteId: e.target.value,
}))
}
placeholder='e.g. 12345678-abcd-efgh-ijkl-1234567890ab'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Umami Website ID
</p>
</div>
</>
)}
{siteSettings.AnalyticsProvider === 'google' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Measurement ID
</label>
<input
type='text'
value={siteSettings.AnalyticsWebsiteId}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsWebsiteId: e.target.value,
}))
}
placeholder='G-XXXXXXXXXX'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Google Analytics 4 Measurement ID GA
</p>
</div>
)}
{siteSettings.AnalyticsProvider === 'clarity' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Project ID
</label>
<input
type='text'
value={siteSettings.AnalyticsWebsiteId}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsWebsiteId: e.target.value,
}))
}
placeholder='e.g. abc1234567'
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Microsoft Clarity Project ID clarity.microsoft.com
</p>
</div>
)}
{siteSettings.AnalyticsProvider === 'custom' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<textarea
value={siteSettings.AnalyticsCustomScript}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
AnalyticsCustomScript: e.target.value,
}))
}
placeholder='粘贴完整的统计脚本代码,如百度统计、Plausible、51la 等...'
rows={6}
className='mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&lt;head&gt;
</p>
</div>
)}
</>
)}
</div>
</details>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button
@@ -249,8 +249,8 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
return null;
}
// D1 存储:使用 getUserPasswordHash 方法
if (storageType === 'd1') {
// D1/Turso 存储:使用 getUserPasswordHash 方法
if (storageType === 'd1' || storageType === 'turso') {
if (typeof storage.getUserPasswordHash === 'function') {
return await storage.getUserPasswordHash(username);
}
@@ -180,8 +180,8 @@ export async function POST(req: NextRequest) {
const createdAt = userV2?.created_at || Date.now();
// 根据存储类型使用不同的导入方法
if (storageType === 'd1') {
// D1 存储:使用 createUserWithHashedPassword 方法
if (storageType === 'd1' || storageType === 'turso') {
// D1/Turso 存储:使用 createUserWithHashedPassword 方法
if (typeof storage.createUserWithHashedPassword === 'function') {
await storage.createUserWithHashedPassword(
username,
@@ -193,9 +193,9 @@ export async function POST(req: NextRequest) {
userV2?.enabledApis,
userV2?.banned
);
console.log(`用户 ${username} 导入成功 (D1)`);
console.log(`用户 ${username} 导入成功 (${storageType})`);
} else {
console.error(`D1 storage 缺少 createUserWithHashedPassword 方法`);
console.error(`${storageType} storage 缺少 createUserWithHashedPassword 方法`);
return false;
}
} else if (storageType === 'postgres') {
+25 -1
View File
@@ -83,6 +83,11 @@ export async function POST(request: NextRequest) {
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
AnalyticsEnabled,
AnalyticsProvider,
AnalyticsScriptUrl,
AnalyticsWebsiteId,
AnalyticsCustomScript,
} = body as {
SiteName: string;
Announcement: string;
@@ -138,6 +143,11 @@ export async function POST(request: NextRequest) {
OIDCClientSecret?: string;
OIDCButtonText?: string;
OIDCMinTrustLevel?: number;
AnalyticsEnabled?: boolean;
AnalyticsProvider?: 'umami' | 'google' | 'clarity' | 'custom';
AnalyticsScriptUrl?: string;
AnalyticsWebsiteId?: string;
AnalyticsCustomScript?: string;
};
// 参数校验
@@ -224,7 +234,16 @@ export async function POST(request: NextRequest) {
(OIDCClientSecret !== undefined &&
typeof OIDCClientSecret !== 'string') ||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number') ||
(AnalyticsEnabled !== undefined && typeof AnalyticsEnabled !== 'boolean') ||
(AnalyticsProvider !== undefined &&
AnalyticsProvider !== 'umami' &&
AnalyticsProvider !== 'google' &&
AnalyticsProvider !== 'clarity' &&
AnalyticsProvider !== 'custom') ||
(AnalyticsScriptUrl !== undefined && typeof AnalyticsScriptUrl !== 'string') ||
(AnalyticsWebsiteId !== undefined && typeof AnalyticsWebsiteId !== 'string') ||
(AnalyticsCustomScript !== undefined && typeof AnalyticsCustomScript !== 'string')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -295,6 +314,11 @@ export async function POST(request: NextRequest) {
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
AnalyticsEnabled,
AnalyticsProvider,
AnalyticsScriptUrl,
AnalyticsWebsiteId,
AnalyticsCustomScript,
};
// 写入数据库
+1
View File
@@ -244,6 +244,7 @@ function DuanjuPageClient() {
year={item.year}
from='source-search'
type='tv'
isDuanju
cmsData={{
desc: item.desc,
episodes: item.episodes,
+49
View File
@@ -115,6 +115,11 @@ export default async function RootLayout({
let liveEnabled = true;
let webLiveEnabled = false;
let customAdFilterVersion = 0;
let analyticsEnabled = false;
let analyticsProvider: 'umami' | 'google' | 'clarity' | 'custom' = 'umami';
let analyticsScriptUrl = '';
let analyticsWebsiteId = '';
let analyticsCustomScript = '';
let musicFeatureEnabled = false;
let suwayomiEnabled = false;
let booksEnabled =
@@ -202,6 +207,12 @@ export default async function RootLayout({
webLiveEnabled = config.WebLiveEnabled ?? false;
// 自定义去广告代码版本号
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
// 流量统计配置
analyticsEnabled = config.SiteConfig?.AnalyticsEnabled || false;
analyticsProvider = config.SiteConfig?.AnalyticsProvider || 'umami';
analyticsScriptUrl = config.SiteConfig?.AnalyticsScriptUrl || '';
analyticsWebsiteId = config.SiteConfig?.AnalyticsWebsiteId || '';
analyticsCustomScript = config.SiteConfig?.AnalyticsCustomScript || '';
// 音乐功能配置
musicFeatureEnabled = config.MusicConfig?.Enabled || false;
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
@@ -335,6 +346,44 @@ export default async function RootLayout({
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
{/* 流量统计脚本 */}
{analyticsEnabled && analyticsProvider === 'umami' && analyticsScriptUrl && (
<>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
async
defer
data-website-id={analyticsWebsiteId}
src={analyticsScriptUrl}
/>
</>
)}
{analyticsEnabled && analyticsProvider === 'google' && analyticsWebsiteId && (
<>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${analyticsWebsiteId}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '${analyticsWebsiteId}');`,
}}
/>
</>
)}
{analyticsEnabled && analyticsProvider === 'clarity' && analyticsWebsiteId && (
<script
dangerouslySetInnerHTML={{
__html: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,"clarity","script","${analyticsWebsiteId}");`,
}}
/>
)}
{analyticsEnabled && analyticsProvider === 'custom' && analyticsCustomScript && (
<script
dangerouslySetInnerHTML={{ __html: analyticsCustomScript }}
/>
)}
</head>
<body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
+6
View File
@@ -8736,6 +8736,12 @@ function PlayPageClient() {
// 条件:当前播放时间 < 10秒 且 播放记录时间 > 10秒
const checkPlayRecordJump = async () => {
try {
// 短剧不显示"上次播放到"提示(短剧单集太短,提示意义不大)
if (searchParams.get('duanju') === '1') {
playRecordJumpInitialCheckRef.current = false;
return;
}
// 仅在进入播放后的首次检查时处理,避免本次会话新生成的记录触发恢复按钮
if (!playRecordJumpInitialCheckRef.current) {
return;
+6 -2
View File
@@ -94,6 +94,7 @@ export interface VideoCardProps {
episodes_titles?: string[];
};
onBeforeNavigate?: () => void;
isDuanju?: boolean; // 短剧标识,用于播放页跳过"上次播放到"提示
}
export type VideoCardHandle = {
@@ -134,6 +135,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
totalTime,
cmsData,
onBeforeNavigate,
isDuanju,
}: VideoCardProps,
ref
) {
@@ -447,7 +449,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isDuanju ? '&duanju=1' : ''}`;
if (isCurrentlyOnPlayPage) {
// 在 play 页面内,添加 _reload 参数强制刷新
@@ -471,6 +473,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
actualQuery,
actualSearchType,
onBeforeNavigate,
isDuanju,
]);
// 新标签页播放处理函数
@@ -509,7 +512,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isDuanju ? '&duanju=1' : ''}`;
window.open(url, '_blank');
}
}, [
@@ -524,6 +527,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
actualQuery,
actualSearchType,
onBeforeNavigate,
isDuanju,
]);
// 检查搜索结果的收藏状态
+6
View File
@@ -73,6 +73,12 @@ export interface AdminConfig {
OIDCClientSecret?: string; // OIDC Client Secret
OIDCButtonText?: string; // OIDC登录按钮文字
OIDCMinTrustLevel?: number; // 最低信任等级(仅LinuxDo网站有效,为0时不判断)
// 流量统计配置
AnalyticsEnabled?: boolean; // 是否启用流量统计
AnalyticsProvider?: 'umami' | 'google' | 'clarity' | 'custom'; // 统计服务提供商
AnalyticsScriptUrl?: string; // 脚本URLUmami: umami.js地址; GA: gtag URL; 自定义: 脚本src
AnalyticsWebsiteId?: string; // 网站IDUmami: website_id; GA: Measurement ID如G-XXXX; 自定义: 留空)
AnalyticsCustomScript?: string; // 自定义统计代码(仅custom模式使用,完整的HTML脚本内容)
};
UserConfig: {
Users: {
+22
View File
@@ -328,6 +328,12 @@ async function getInitConfig(
TurnstileSiteKey: '',
TurnstileSecretKey: '',
DefaultUserTags: [],
// 流量统计配置
AnalyticsEnabled: false,
AnalyticsProvider: 'umami',
AnalyticsScriptUrl: '',
AnalyticsWebsiteId: '',
AnalyticsCustomScript: '',
},
UserConfig: {
Users: [],
@@ -588,6 +594,22 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
adminConfig.SiteConfig.DefaultUserTags = [];
}
// 流量统计配置补全
if (adminConfig.SiteConfig.AnalyticsEnabled === undefined) {
adminConfig.SiteConfig.AnalyticsEnabled = false;
}
if (adminConfig.SiteConfig.AnalyticsProvider === undefined) {
adminConfig.SiteConfig.AnalyticsProvider = 'umami';
}
if (adminConfig.SiteConfig.AnalyticsScriptUrl === undefined) {
adminConfig.SiteConfig.AnalyticsScriptUrl = '';
}
if (adminConfig.SiteConfig.AnalyticsWebsiteId === undefined) {
adminConfig.SiteConfig.AnalyticsWebsiteId = '';
}
if (adminConfig.SiteConfig.AnalyticsCustomScript === undefined) {
adminConfig.SiteConfig.AnalyticsCustomScript = '';
}
if (!adminConfig.TelegramConfig) {
adminConfig.TelegramConfig = {
enabled: process.env.TELEGRAM_BOT_ENABLED === 'true' || Boolean(process.env.TELEGRAM_BOT_TOKEN),
+34 -1
View File
@@ -17,7 +17,7 @@ import {
SkipConfig,
} from './types';
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage'
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres' | 'turso',默认 'localstorage'
const IS_CLOUDFLARE_BUILD =
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
const STORAGE_TYPE =
@@ -28,6 +28,7 @@ const STORAGE_TYPE =
| 'kvrocks'
| 'd1'
| 'postgres'
| 'turso'
| undefined) || 'localstorage';
// 创建存储实例
@@ -70,6 +71,15 @@ function createStorage(): IStorage {
// 动态导入 PostgresStorage 以避免客户端打包
const { PostgresStorage } = require('./postgres.db');
return new PostgresStorage(postgresAdapter);
case 'turso':
// TursoStorage 只能在服务端使用,客户端会报错
if (typeof window !== 'undefined') {
throw new Error('TursoStorage can only be used on the server side');
}
const tursoAdapter = getTursoAdapter();
// 复用 D1StorageTurso 基于 libSQL/SQLiteSQL 语法完全兼容)
const { D1Storage: TursoD1Storage } = require('./d1.db');
return new TursoD1Storage(tursoAdapter);
case 'localstorage':
default:
return null as unknown as IStorage;
@@ -89,6 +99,29 @@ function getPostgresAdapter(): any {
return new PostgresAdapter();
}
/**
* 获取 Turso 适配器
* 使用 @libsql/client 连接 Turso (libSQL) 远程数据库
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
*/
function getTursoAdapter(): any {
// 动态导入适配器以避免客户端打包
const { TursoAdapter } = require('./turso-adapter');
const tursoUrl = process.env.TURSO_URL;
const tursoToken = process.env.TURSO_TOKEN;
if (!tursoUrl || !tursoToken) {
throw new Error(
'TURSO_URL and TURSO_TOKEN env variables must be set for Turso storage'
);
}
console.log('Using Turso (libSQL) database');
return new TursoAdapter(tursoUrl, tursoToken);
}
/**
* 获取 D1 适配器
* 开发环境:使用 better-sqlite3
+173
View File
@@ -0,0 +1,173 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Turso (libSQL) 适配器
*
* 将 @libsql/client API 转换为与 D1 兼容的接口
* Turso 基于 libSQLSQLite 开源分支),SQL 语法与 D1/SQLite 完全兼容
*
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
*
* 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包
*/
import { DatabaseAdapter, D1PreparedStatement, D1Result } from './d1-adapter';
/**
* 动态加载 @libsql/client 的 createClient 函数
*
* 使用 require 而非 import,避免 webpack 在 EdgeOne 构建中
* 因条件导出 (edge-light) 错误解析 ESM 模块
*
* 使用 @libsql/client/http 子路径而非主入口,避免拉入原生 libsql
* 模块和 isomorphic-ws/isomorphic-fetch 等不兼容边缘环境的依赖
*/
function getLibsqlClient(): any {
const mod = require('@libsql/client/http');
return mod.createClient || mod.default?.createClient;
}
/**
* Turso 适配器
*
* 使用 @libsql/client 包装为 D1 兼容接口
*/
export class TursoAdapter implements DatabaseAdapter {
private client: any;
constructor(url: string, authToken: string) {
const createClient = getLibsqlClient();
this.client = createClient({
url,
authToken,
});
}
prepare(query: string): D1PreparedStatement {
return new TursoPreparedStatement(this.client, query);
}
async batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
// Turso/libSQL 原生支持 batch
const libsqlStatements = statements.map(
(stmt) => (stmt as TursoPreparedStatement).toLibSQLBatch()
);
const results = await this.client.batch(libsqlStatements, 'write');
return results.map((result: any) => ({
success: true,
results: result.rows || [],
meta: {
changes: result.rowsAffected,
last_row_id:
result.lastInsertRowid !== undefined
? Number(result.lastInsertRowid)
: null,
},
}));
}
async exec(query: string): Promise<void> {
await this.client.executeMultiple(query);
}
}
/**
* Turso PreparedStatement 包装器
* 将 @libsql/client API 转换为 D1 兼容 API
*/
class TursoPreparedStatement implements D1PreparedStatement {
private params: any[] = [];
constructor(
private client: any,
private query: string
) {}
bind(...values: any[]): D1PreparedStatement {
this.params = values;
return this;
}
/**
* 执行查询并返回第一行
*/
async first<T = any>(colName?: string): Promise<T | null> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
if (!result.rows || result.rows.length === 0) return null;
const row = result.rows[0];
if (colName) return (row as any)[colName] ?? null;
return row as T;
} catch (err) {
console.error('Turso first() error:', err);
return null;
}
}
/**
* 执行查询并返回结果
*/
async run<T = any>(): Promise<D1Result<T>> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
return {
success: true,
meta: {
changes: result.rowsAffected,
last_row_id:
result.lastInsertRowid !== undefined
? Number(result.lastInsertRowid)
: null,
},
results: result.rows as T[],
};
} catch (err: any) {
console.error('Turso run() error:', err);
return {
success: false,
error: err.message,
};
}
}
/**
* 执行查询并返回所有行
*/
async all<T = any>(): Promise<D1Result<T>> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
return {
success: true,
results: (result.rows || []) as T[],
};
} catch (err: any) {
console.error('Turso all() error:', err);
return {
success: false,
error: err.message,
results: [],
};
}
}
/**
* 转换为 libSQL batch 格式
*/
toLibSQLBatch(): { sql: string; args: any[] } {
return { sql: this.query, args: this.params };
}
}
+18 -4
View File
@@ -399,6 +399,11 @@ export function processImageUrl(originalUrl: string): string {
return originalUrl;
}
// 如果不是 http/https 开头(相对路径、data URI 等),直接返回
if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://')) {
return originalUrl;
}
// 处理 TMDB 图片 URL 替换
if (originalUrl.includes('image.tmdb.org')) {
if (typeof window !== 'undefined') {
@@ -424,12 +429,21 @@ export function processImageUrl(originalUrl: string): string {
}
// 处理豆瓣图片代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;
if (originalUrl.includes('doubanio.com')) {
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
// 其他图片(视频源封面图等)走本站代理,带缓存
// 可通过 localStorage 'proxyAllImages' = 'false' 关闭
if (typeof window !== 'undefined') {
const proxyAll = localStorage.getItem('proxyAllImages');
if (proxyAll !== 'false') {
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
}
}
return originalUrl;
}
/**