diff --git a/.env.edgeone.example b/.env.edgeone.example
index bc69d97..75ae1df 100644
--- a/.env.edgeone.example
+++ b/.env.edgeone.example
@@ -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 Redis(KV 存储)
+# 1. 注册 https://upstash.com 获取免费 Redis
+# 2. 设置 NEXT_PUBLIC_STORAGE_TYPE=upstash
+# 3. 配置 UPSTASH_URL 和 UPSTASH_TOKEN
# 管理员账号(请替换)
USERNAME=admin
diff --git a/.github/workflows/edgeone-deploy.yml b/.github/workflows/edgeone-deploy.yml
index 60b476c..331adf5 100644
--- a/.github/workflows/edgeone-deploy.yml
+++ b/.github/workflows/edgeone-deploy.yml
@@ -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
diff --git a/README.md b/README.md
index 29a0348..3b22151 100644
--- a/README.md
+++ b/README.md
@@ -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 | (空) |
diff --git a/next.config.js b/next.config.js
index c825808..d4c0658 100644
--- a/next.config.js
+++ b/next.config.js
@@ -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,
};
}
diff --git a/package.json b/package.json
index 5913e89..8669965 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4601a9..cc1987e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@18.3.1)
+ '@libsql/client':
+ specifier: ^0.15.0
+ version: 0.15.15(bufferutil@4.1.0)
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
@@ -2566,6 +2569,67 @@ packages:
'@jspm/core@2.1.0':
resolution: {integrity: sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==}
+ '@libsql/client@0.15.15':
+ resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==}
+
+ '@libsql/core@0.15.15':
+ resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==}
+
+ '@libsql/darwin-arm64@0.5.29':
+ resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@libsql/darwin-x64@0.5.29':
+ resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@libsql/hrana-client@0.7.0':
+ resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==}
+
+ '@libsql/isomorphic-fetch@0.3.1':
+ resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==}
+ engines: {node: '>=18.0.0'}
+
+ '@libsql/isomorphic-ws@0.1.5':
+ resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
+
+ '@libsql/linux-arm-gnueabihf@0.5.29':
+ resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@libsql/linux-arm-musleabihf@0.5.29':
+ resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@libsql/linux-arm64-gnu@0.5.29':
+ resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-arm64-musl@0.5.29':
+ resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-x64-gnu@0.5.29':
+ resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/linux-x64-musl@0.5.29':
+ resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/win32-x64-msvc@0.5.29':
+ resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
+ cpu: [x64]
+ os: [win32]
+
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -2580,6 +2644,9 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
+ '@neon-rs/load@0.0.4':
+ resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
+
'@neondatabase/serverless@0.9.5':
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
@@ -3817,6 +3884,9 @@ packages:
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
+ '@types/ws@8.18.1':
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
'@types/xml2js@0.4.14':
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
@@ -5485,6 +5555,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+ detect-libc@2.0.2:
+ resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
+ engines: {node: '>=8'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -7180,6 +7254,9 @@ packages:
jose@5.9.6:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
+ js-base64@3.8.1:
+ resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==}
+
js-string-escape@1.0.1:
resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
engines: {node: '>= 0.8'}
@@ -7333,6 +7410,11 @@ packages:
libphonenumber-js@1.12.33:
resolution: {integrity: sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==}
+ libsql@0.5.29:
+ resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
+ cpu: [x64, arm64, wasm32, arm]
+ os: [darwin, linux, win32]
+
lilconfig@3.1.3:
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'}
+ promise-limit@2.7.0:
+ resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
+
promisepipe@3.0.0:
resolution: {integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==}
@@ -9691,10 +9776,6 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
- type-fest@5.3.1:
- resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==}
- engines: {node: '>=20'}
-
type-fest@5.7.0:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
engines: {node: '>=20'}
@@ -13716,6 +13797,68 @@ snapshots:
'@jspm/core@2.1.0': {}
+ '@libsql/client@0.15.15(bufferutil@4.1.0)':
+ dependencies:
+ '@libsql/core': 0.15.15
+ '@libsql/hrana-client': 0.7.0(bufferutil@4.1.0)
+ js-base64: 3.8.1
+ libsql: 0.5.29
+ promise-limit: 2.7.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/core@0.15.15':
+ dependencies:
+ js-base64: 3.8.1
+
+ '@libsql/darwin-arm64@0.5.29':
+ optional: true
+
+ '@libsql/darwin-x64@0.5.29':
+ optional: true
+
+ '@libsql/hrana-client@0.7.0(bufferutil@4.1.0)':
+ dependencies:
+ '@libsql/isomorphic-fetch': 0.3.1
+ '@libsql/isomorphic-ws': 0.1.5(bufferutil@4.1.0)
+ js-base64: 3.8.1
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/isomorphic-fetch@0.3.1': {}
+
+ '@libsql/isomorphic-ws@0.1.5(bufferutil@4.1.0)':
+ dependencies:
+ '@types/ws': 8.18.1
+ ws: 8.21.0(bufferutil@4.1.0)
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/linux-arm-gnueabihf@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm-musleabihf@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm64-gnu@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm64-musl@0.5.29':
+ optional: true
+
+ '@libsql/linux-x64-gnu@0.5.29':
+ optional: true
+
+ '@libsql/linux-x64-musl@0.5.29':
+ optional: true
+
+ '@libsql/win32-x64-msvc@0.5.29':
+ optional: true
+
'@mapbox/node-pre-gyp@2.0.3':
dependencies:
consola: 3.4.2
@@ -13745,6 +13888,8 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@neon-rs/load@0.0.4': {}
+
'@neondatabase/serverless@0.9.5':
dependencies:
'@types/pg': 8.11.6
@@ -15139,11 +15284,11 @@ snapshots:
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/eslint@9.6.1':
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/estree-jsx@1.0.5':
@@ -15285,6 +15430,10 @@ snapshots:
'@types/validator@13.15.10': {}
+ '@types/ws@8.18.1':
+ dependencies:
+ '@types/node': 24.0.3
+
'@types/xml2js@0.4.14':
dependencies:
'@types/node': 24.0.3
@@ -16053,9 +16202,9 @@ snapshots:
dependencies:
acorn: 8.15.0
- acorn-import-phases@1.0.4(acorn@8.15.0):
+ acorn-import-phases@1.0.4(acorn@8.17.0):
dependencies:
- acorn: 8.15.0
+ acorn: 8.17.0
acorn-jsx@5.3.2(acorn@8.15.0):
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
conf@9.0.2:
@@ -17226,6 +17375,8 @@ snapshots:
destr@2.0.5: {}
+ detect-libc@2.0.2: {}
+
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
@@ -17354,7 +17505,7 @@ snapshots:
dot-prop@10.1.0:
dependencies:
- type-fest: 5.3.1
+ type-fest: 5.7.0
dot-prop@6.0.1:
dependencies:
@@ -18979,7 +19130,7 @@ snapshots:
is-fullwidth-code-point@5.1.0:
dependencies:
- get-east-asian-width: 1.4.0
+ get-east-asian-width: 1.6.0
is-generator-fn@2.1.0: {}
@@ -19620,6 +19771,8 @@ snapshots:
jose@5.9.6: {}
+ js-base64@3.8.1: {}
+
js-string-escape@1.0.1: {}
js-tokens@4.0.0: {}
@@ -19774,6 +19927,21 @@ snapshots:
libphonenumber-js@1.12.33: {}
+ libsql@0.5.29:
+ 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
+
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -20996,6 +21164,8 @@ snapshots:
process@0.11.10: {}
+ promise-limit@2.7.0: {}
+
promisepipe@3.0.0: {}
prompts@2.4.2:
@@ -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:
type-fest@4.41.0: {}
- type-fest@5.3.1:
- dependencies:
- tagged-tag: 1.0.0
-
type-fest@5.7.0:
dependencies:
tagged-tag: 1.0.0
@@ -22805,13 +22971,13 @@ snapshots:
webpack@5.104.1(esbuild@0.19.12):
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(acorn@8.15.0)
+ acorn: 8.17.0
+ acorn-import-phases: 1.0.4(acorn@8.17.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.4
diff --git a/scripts/edgeone-build.mjs b/scripts/edgeone-build.mjs
index de66336..e903dc8 100644
--- a/scripts/edgeone-build.mjs
+++ b/scripts/edgeone-build.mjs
@@ -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',
diff --git a/scripts/init-turso.js b/scripts/init-turso.js
new file mode 100644
index 0000000..84e68f3
--- /dev/null
+++ b/scripts/init-turso.js
@@ -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();
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 43e202c..2175885 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -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 = ({
+ {/* 流量统计配置 */}
+
+ 开启后将在页面中注入统计脚本,支持 Umami、Google Analytics 和自定义代码
+
+ Umami 实例的 script.js 完整 URL
+
+ 在 Umami 后台添加网站后获取的 Website ID
+
+ Google Analytics 4 的 Measurement ID,在 GA 后台「数据流」中获取
+
+ Microsoft Clarity 的 Project ID,在 clarity.microsoft.com 项目设置中获取
+
+
+
+