diff --git a/.dockerignore b/.dockerignore
index 2d1feac..d74733c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,2 +1,5 @@
.env
-.env*.local
\ No newline at end of file
+.env*.local
+public/screenshot1.png
+public/screenshot2.png
+public/screenshot3.png
diff --git a/.github/workflows/docker-image-lite.yml b/.github/workflows/docker-image-lite.yml
new file mode 100644
index 0000000..f42b747
--- /dev/null
+++ b/.github/workflows/docker-image-lite.yml
@@ -0,0 +1,125 @@
+name: Build & Push Docker lite image
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Docker 标签'
+ required: false
+ default: 'latest'
+ type: string
+ push:
+ branches: [ main ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ packages: write
+ actions: write
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ - platform: linux/amd64
+ os: ubuntu-latest
+ - platform: linux/arm64
+ os: ubuntu-24.04-arm
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Prepare platform name
+ run: |
+ echo "PLATFORM_NAME=${{ matrix.platform }}" | sed 's|/|-|g' >> $GITHUB_ENV
+
+ - name: Checkout source code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/mtvpls/moontvplus-lite
+ tags: |
+ type=raw,value=${{ github.event.inputs.tag || 'latest' }}
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile.lite
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ tags: ghcr.io/mtvpls/moontvplus-lite:${{ github.event.inputs.tag || 'latest' }}
+ outputs: type=image,name=ghcr.io/mtvpls/moontvplus-lite,name-canonical=true,push=true
+
+ - name: Export digest
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ env.PLATFORM_NAME }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ runs-on: ubuntu-latest
+ needs:
+ - build
+ steps:
+ - name: Download digests
+ uses: actions/download-artifact@v4
+ with:
+ path: /tmp/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create manifest list and push
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create -t ghcr.io/mtvpls/moontvplus-lite:${{ github.event.inputs.tag || 'latest' }} \
+ $(printf 'ghcr.io/mtvpls/moontvplus-lite@sha256:%s ' *)
+
+ cleanup-refresh:
+ runs-on: ubuntu-latest
+ needs:
+ - merge
+ if: always()
+ steps:
+ - name: Delete workflow runs
+ uses: Mattraks/delete-workflow-runs@main
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ repository: ${{ github.repository }}
+ retain_days: 0
+ keep_minimum_runs: 2
diff --git a/.husky/commit-msg b/.husky/commit-msg
deleted file mode 100644
index 0bd658f..0000000
--- a/.husky/commit-msg
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
-npx --no-install commitlint --edit "$1"
diff --git a/.husky/post-merge b/.husky/post-merge
deleted file mode 100644
index 1fd4a5b..0000000
--- a/.husky/post-merge
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
-pnpm install
diff --git a/.husky/pre-commit b/.husky/pre-commit
deleted file mode 100644
index c37466e..0000000
--- a/.husky/pre-commit
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
-npx lint-staged
\ No newline at end of file
diff --git a/CHANGELOG b/CHANGELOG
index 0d90e0c..36fdd2a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,25 @@
+## [216.0.0] - 2026-03-30
+### Added
+- 新增视频源脚本
+- 私人影库增加本机转码功能
+- tvbox订阅增加黄色过滤
+- 播放记录显示直链播放的链接
+- 磁链增加代理配置
+- 弹幕选集面板增强
+- 电视直播聚合同名节目
+- douban页面大屏自动预加载第二页
+- 增加docker lite镜像
+- 详情面板增加外部跳转
+
+### Changed
+- GlobalError自动消失
+- 站点配置子服务配置项折叠
+
+### Fixed
+- 修复手动选择弹幕因缓存问题无法变更弹幕集数
+- 修复当前集拉回开头显示恢复进度按钮
+- 修复视频源权重的一些问题
+
## [215.0.0] - 2026-03-20
### Added
- 增加主动恢复进度按钮
diff --git a/Dockerfile.lite b/Dockerfile.lite
new file mode 100644
index 0000000..85088d7
--- /dev/null
+++ b/Dockerfile.lite
@@ -0,0 +1,52 @@
+# ---- 第 1 阶段:安装依赖 ----
+FROM node:24-alpine AS deps
+
+RUN corepack enable && corepack prepare pnpm@latest --activate
+
+WORKDIR /app
+
+COPY package.json pnpm-lock.yaml ./
+
+RUN pnpm install --frozen-lockfile
+
+# ---- 第 2 阶段:构建项目 ----
+FROM node:24-alpine AS builder
+RUN corepack enable && corepack prepare pnpm@latest --activate
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+ENV DOCKER_ENV=true
+ENV MOONTV_LITE=true
+ENV WATCH_ROOM_ENABLED=false
+ENV WATCH_ROOM_SERVER_TYPE=external
+
+RUN pnpm run build
+
+# ---- 第 3 阶段:生成 lite 运行时镜像 ----
+FROM node:24-alpine AS runner
+
+RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
+
+WORKDIR /app
+ENV NODE_ENV=production
+ENV HOSTNAME=0.0.0.0
+ENV PORT=3000
+ENV DOCKER_ENV=true
+ENV MOONTV_LITE=true
+ENV WATCH_ROOM_ENABLED=false
+ENV WATCH_ROOM_SERVER_TYPE=external
+
+# standalone 输出自带运行所需的最小服务端依赖
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
+COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+CMD ["node", "start.js"]
diff --git a/README.md b/README.md
index 2f2828e..7f2d057 100644
--- a/README.md
+++ b/README.md
@@ -27,11 +27,13 @@
- ✨ **视频超分 (Anime4K)**:使用 WebGPU 技术实现实时视频画质增强(支持 1.5x/2x/3x/4x 超分)
- 💬 **弹幕系统**:完整的弹幕搜索、匹配、加载功能,支持弹幕设置持久化、弹幕屏蔽
- 📝 **豆瓣评论抓取**:自动抓取并展示豆瓣电影短评,支持分页加载
+- 🧩 **视频源脚本**:支持通过脚本自定义视频源、搜索、详情与播放解析逻辑(实验性)
- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能
+- 🚀 **更快更顺滑**:相较原版项目整体速度更快,交互体验更好
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
-- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
+- 📥 **M3U8完整下载**:支持浏览器内合并 m3u8 片段下载,也支持下载到本地文件夹并无感播放本地视频。
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
-- 📚 **私人影库**:接入 OpenList或Emby,可打造专属私人影库,亦可观看网盘资源。
+- 📚 **私人影库**:接入 OpenList、Emby 或小雅,可打造专属私人影库,亦可观看网盘资源。
## ✨ 功能特性
@@ -283,6 +285,44 @@ services:
- UPSTASH_TOKEN=上面的 TOKEN
```
+#### Lite 镜像说明
+
+`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务。
+
+示例:
+
+```yml
+services:
+ moontv-core:
+ image: ghcr.io/mtvpls/moontvplus-lite:latest
+ container_name: moontv-core
+ restart: on-failure
+ ports:
+ - '3000:3000'
+ environment:
+ - USERNAME=admin
+ - PASSWORD=admin_password
+ - NEXT_PUBLIC_STORAGE_TYPE=kvrocks
+ - KVROCKS_URL=redis://moontv-kvrocks:6666
+ networks:
+ - moontv-network
+ depends_on:
+ - moontv-kvrocks
+ moontv-kvrocks:
+ image: apache/kvrocks
+ container_name: moontv-kvrocks
+ restart: unless-stopped
+ volumes:
+ - kvrocks-data:/var/lib/kvrocks/data
+ networks:
+ - moontv-network
+networks:
+ moontv-network:
+ driver: bridge
+volumes:
+ kvrocks-data:
+```
+
## 配置文件
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件,本版本已不支持无数据库运行。
diff --git a/SOURCE_SCRIPT.md b/SOURCE_SCRIPT.md
new file mode 100644
index 0000000..aefab62
--- /dev/null
+++ b/SOURCE_SCRIPT.md
@@ -0,0 +1,203 @@
+# 视频源脚本编写教程
+
+## 最小模板
+
+```js
+return {
+ meta: {
+ name: '示例脚本',
+ author: 'admin'
+ },
+
+ async getSources(ctx) {
+ return [{ id: 'default', name: '默认源' }];
+ },
+
+ async search(ctx, { keyword, page, sourceId }) {
+ return {
+ list: [],
+ page,
+ pageCount: 1,
+ total: 0
+ };
+ },
+
+ async recommend(ctx, { page }) {
+ return {
+ list: [],
+ page: page || 1,
+ pageCount: 1,
+ total: 0
+ };
+ },
+
+ async detail(ctx, { id, sourceId }) {
+ return {
+ id,
+ title: '',
+ poster: '',
+ year: '',
+ desc: '',
+ playbacks: [
+ {
+ sourceId,
+ sourceName: '默认源',
+ episodes: [],
+ episodes_titles: []
+ }
+ ]
+ };
+ },
+
+ async resolvePlayUrl(ctx, { playUrl, sourceId, episodeIndex }) {
+ return {
+ url: playUrl,
+ type: 'auto',
+ headers: {}
+ };
+ }
+};
+```
+
+## 支持的 hook
+
+1. `getSources()`
+ 返回脚本管理的子源列表
+2. `search({ keyword, page, sourceId })`
+ 搜索
+3. `recommend({ page })`
+ 推荐
+4. `detail({ id, sourceId })`
+ 详情
+5. `resolvePlayUrl({ playUrl, sourceId, episodeIndex })`
+ 播放前解析最终地址
+
+## `ctx` 里能用什么
+
+1. `ctx.fetch(...)`
+ 发请求
+2. `ctx.request.get/getJson/getHtml/post`
+ 快捷请求
+3. `ctx.html.load(html)`
+ `cheerio` 风格解析
+4. `ctx.cache.get/set/del`
+ 脚本缓存
+5. `ctx.log.info/warn/error`
+ 输出测试日志
+6. `ctx.utils.buildUrl/joinUrl/randomUA/sleep/base64Encode/base64Decode/now`
+ 常用工具
+7. `ctx.config.get/require/all`
+ 读脚本配置
+8. `ctx.runtime`
+ 当前脚本信息
+
+## `search` 返回格式
+
+```js
+{
+ list: [
+ {
+ id: '123',
+ title: '凡人修仙传',
+ poster: 'https://...',
+ year: '2025',
+ desc: '简介',
+ type_name: '动漫',
+ douban_id: 0,
+ vod_remarks: '更新至10集'
+ }
+ ],
+ page: 1,
+ pageCount: 1,
+ total: 1
+}
+```
+
+## `detail` 返回格式
+
+```js
+{
+ id: '123',
+ title: '凡人修仙传',
+ poster: 'https://...',
+ year: '2025',
+ desc: '简介',
+ playbacks: [
+ {
+ sourceId: 'default',
+ sourceName: '默认源',
+ episodes: [
+ 'https://example.com/play/1',
+ {
+ playUrl: 'https://example.com/play/2',
+ needResolve: false
+ }
+ ],
+ episodes_titles: ['第1集', '第2集']
+ }
+ ]
+}
+```
+
+说明:
+
+- `episodes` 可以是字符串,默认等价于 `{ playUrl: '...', needResolve: true }`
+- `needResolve` 默认为 `true`
+- 显式写 `needResolve: false` 时,播放页会直接使用该地址,不再调用 `resolvePlayUrl`
+
+## `resolvePlayUrl` 返回格式
+
+```js
+{
+ url: 'https://real-url.m3u8',
+ type: 'auto',
+ headers: {}
+}
+```
+
+## 最简单的搜索例子
+
+```js
+async search(ctx, { keyword, page, sourceId }) {
+ const data = await ctx.request.getJson('https://example.com/api/search', {
+ query: { wd: keyword, pg: page }
+ });
+
+ return {
+ list: (data.list || []).map((item) => ({
+ id: String(item.id),
+ title: item.title,
+ poster: item.pic || '',
+ year: item.year || '',
+ desc: item.desc || ''
+ })),
+ page,
+ pageCount: data.pagecount || 1,
+ total: data.total || 0
+ };
+}
+```
+
+## 最简单的播放解析例子
+
+```js
+async resolvePlayUrl(ctx, { playUrl }) {
+ return {
+ url: playUrl,
+ type: 'auto',
+ headers: {}
+ };
+}
+```
+
+## 导入格式
+
+```json
+{
+ "key": "demo",
+ "name": "演示脚本",
+ "description": "test",
+ "enabled": true,
+ "code": "return { ... }"
+}
+```
diff --git a/VERSION.txt b/VERSION.txt
index a35024d..b7ea4d1 100644
--- a/VERSION.txt
+++ b/VERSION.txt
@@ -1,2 +1,2 @@
-215.0.0
+216.0.0
diff --git a/commitlint.config.js b/commitlint.config.js
deleted file mode 100644
index 3bf488d..0000000
--- a/commitlint.config.js
+++ /dev/null
@@ -1,24 +0,0 @@
-module.exports = {
- extends: ['@commitlint/config-conventional'],
- rules: {
- // TODO Add Scope Enum Here
- // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
- 'type-enum': [
- 2,
- 'always',
- [
- 'feat',
- 'fix',
- 'docs',
- 'chore',
- 'style',
- 'refactor',
- 'ci',
- 'test',
- 'perf',
- 'revert',
- 'vercel',
- ],
- ],
- },
-};
diff --git a/package.json b/package.json
index 4cebcc6..ba29782 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,6 @@
"format:check": "prettier -c .",
"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",
"init:sqlite": "node scripts/init-sqlite.js",
"init:postgres": "node scripts/init-postgres.js",
@@ -78,8 +77,6 @@
"zod": "^3.24.1"
},
"devDependencies": {
- "@commitlint/cli": "^16.3.0",
- "@commitlint/config-conventional": "^16.2.4",
"@opennextjs/cloudflare": "^1.15.1",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10",
@@ -105,9 +102,7 @@
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
- "husky": "^7.0.4",
"jest": "^27.5.1",
- "lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0",
"postcss": "^8.5.1",
"prettier": "^2.8.8",
@@ -117,14 +112,5 @@
"vercel": "^50.4.10",
"webpack-obfuscator": "^3.5.1"
},
- "lint-staged": {
- "**/*.{js,jsx,ts,tsx}": [
- "eslint --max-warnings=0",
- "prettier -w"
- ],
- "**/*.{json,css,scss,md,webmanifest}": [
- "prettier -w"
- ]
- },
"packageManager": "pnpm@10.14.0"
-}
\ No newline at end of file
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 191b654..eee58c4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -159,12 +159,6 @@ importers:
specifier: ^3.24.1
version: 3.25.76
devDependencies:
- '@commitlint/cli':
- specifier: ^16.3.0
- version: 16.3.0
- '@commitlint/config-conventional':
- specifier: ^16.2.4
- version: 16.2.4
'@opennextjs/cloudflare':
specifier: ^1.15.1
version: 1.15.1(next@14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(wrangler@4.60.0(bufferutil@4.1.0))
@@ -240,15 +234,9 @@ importers:
eslint-plugin-unused-imports:
specifier: ^2.0.0
version: 2.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)
- husky:
- specifier: ^7.0.4
- version: 7.0.4
jest:
specifier: ^27.5.1
version: 27.5.1(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@24.0.3)(typescript@4.9.5))
- lint-staged:
- specifier: ^12.5.0
- version: 12.5.0(enquirer@2.4.1)
next-router-mock:
specifier: ^0.9.0
version: 0.9.13(next@14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
@@ -1452,75 +1440,6 @@ packages:
cpu: [x64]
os: [win32]
- '@commitlint/cli@16.3.0':
- resolution: {integrity: sha512-P+kvONlfsuTMnxSwWE1H+ZcPMY3STFaHb2kAacsqoIkNx66O0T7sTpBxpxkMrFPyhkJiLJnJWMhk4bbvYD3BMA==}
- engines: {node: '>=v12'}
- hasBin: true
-
- '@commitlint/config-conventional@16.2.4':
- resolution: {integrity: sha512-av2UQJa3CuE5P0dzxj/o/B9XVALqYzEViHrMXtDrW9iuflrqCStWBAioijppj9URyz6ONpohJKAtSdgAOE0gkA==}
- engines: {node: '>=v12'}
-
- '@commitlint/config-validator@16.2.1':
- resolution: {integrity: sha512-hogSe0WGg7CKmp4IfNbdNES3Rq3UEI4XRPB8JL4EPgo/ORq5nrGTVzxJh78omibNuB8Ho4501Czb1Er1MoDWpw==}
- engines: {node: '>=v12'}
-
- '@commitlint/ensure@16.2.1':
- resolution: {integrity: sha512-/h+lBTgf1r5fhbDNHOViLuej38i3rZqTQnBTk+xEg+ehOwQDXUuissQ5GsYXXqI5uGy+261ew++sT4EA3uBJ+A==}
- engines: {node: '>=v12'}
-
- '@commitlint/execute-rule@16.2.1':
- resolution: {integrity: sha512-oSls82fmUTLM6cl5V3epdVo4gHhbmBFvCvQGHBRdQ50H/690Uq1Dyd7hXMuKITCIdcnr9umyDkr8r5C6HZDF3g==}
- engines: {node: '>=v12'}
-
- '@commitlint/format@16.2.1':
- resolution: {integrity: sha512-Yyio9bdHWmNDRlEJrxHKglamIk3d6hC0NkEUW6Ti6ipEh2g0BAhy8Od6t4vLhdZRa1I2n+gY13foy+tUgk0i1Q==}
- engines: {node: '>=v12'}
-
- '@commitlint/is-ignored@16.2.4':
- resolution: {integrity: sha512-Lxdq9aOAYCOOOjKi58ulbwK/oBiiKz+7Sq0+/SpFIEFwhHkIVugvDvWjh2VRBXmRC/x5lNcjDcYEwS/uYUvlYQ==}
- engines: {node: '>=v12'}
-
- '@commitlint/lint@16.2.4':
- resolution: {integrity: sha512-AUDuwOxb2eGqsXbTMON3imUGkc1jRdtXrbbohiLSCSk3jFVXgJLTMaEcr39pR00N8nE9uZ+V2sYaiILByZVmxQ==}
- engines: {node: '>=v12'}
-
- '@commitlint/load@16.3.0':
- resolution: {integrity: sha512-3tykjV/iwbkv2FU9DG+NZ/JqmP0Nm3b7aDwgCNQhhKV5P74JAuByULkafnhn+zsFGypG1qMtI5u+BZoa9APm0A==}
- engines: {node: '>=v12'}
-
- '@commitlint/message@16.2.1':
- resolution: {integrity: sha512-2eWX/47rftViYg7a3axYDdrgwKv32mxbycBJT6OQY/MJM7SUfYNYYvbMFOQFaA4xIVZt7t2Alyqslbl6blVwWw==}
- engines: {node: '>=v12'}
-
- '@commitlint/parse@16.2.1':
- resolution: {integrity: sha512-2NP2dDQNL378VZYioLrgGVZhWdnJO4nAxQl5LXwYb08nEcN+cgxHN1dJV8OLJ5uxlGJtDeR8UZZ1mnQ1gSAD/g==}
- engines: {node: '>=v12'}
-
- '@commitlint/read@16.2.1':
- resolution: {integrity: sha512-tViXGuaxLTrw2r7PiYMQOFA2fueZxnnt0lkOWqKyxT+n2XdEMGYcI9ID5ndJKXnfPGPppD0w/IItKsIXlZ+alw==}
- engines: {node: '>=v12'}
-
- '@commitlint/resolve-extends@16.2.1':
- resolution: {integrity: sha512-NbbCMPKTFf2J805kwfP9EO+vV+XvnaHRcBy6ud5dF35dxMsvdJqke54W3XazXF1ZAxC4a3LBy4i/GNVBAthsEg==}
- engines: {node: '>=v12'}
-
- '@commitlint/rules@16.2.4':
- resolution: {integrity: sha512-rK5rNBIN2ZQNQK+I6trRPK3dWa0MtaTN4xnwOma1qxa4d5wQMQJtScwTZjTJeallFxhOgbNOgr48AMHkdounVg==}
- engines: {node: '>=v12'}
-
- '@commitlint/to-lines@16.2.1':
- resolution: {integrity: sha512-9/VjpYj5j1QeY3eiog1zQWY6axsdWAc0AonUUfyZ7B0MVcRI0R56YsHAfzF6uK/g/WwPZaoe4Lb1QCyDVnpVaQ==}
- engines: {node: '>=v12'}
-
- '@commitlint/top-level@16.2.1':
- resolution: {integrity: sha512-lS6GSieHW9y6ePL73ied71Z9bOKyK+Ib9hTkRsB8oZFAyQZcyRwq2w6nIa6Fngir1QW51oKzzaXfJL94qwImyw==}
- engines: {node: '>=v12'}
-
- '@commitlint/types@16.2.1':
- resolution: {integrity: sha512-7/z7pA7BM0i8XvMSBynO7xsB3mVQPUZbVn6zMIlp/a091XJ3qAXRXc+HwLYhiIdzzS5fuxxNIHZMGHVD4HJxdA==}
- engines: {node: '>=v12'}
-
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -3260,9 +3179,6 @@ packages:
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
- '@types/minimist@1.2.5':
- resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
-
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -3281,15 +3197,9 @@ packages:
'@types/nodemailer@7.0.5':
resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==}
- '@types/normalize-package-data@2.4.4':
- resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
-
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
- '@types/parse-json@4.0.2':
- resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
-
'@types/pg@8.11.6':
resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==}
@@ -3678,10 +3588,6 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
- JSONStream@1.3.5:
- resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
- hasBin: true
-
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@@ -3751,10 +3657,6 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
- aggregate-error@3.1.0:
- resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
- engines: {node: '>=8'}
-
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -3864,9 +3766,6 @@ packages:
resolution: {integrity: sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==}
engines: {node: '>=8'}
- array-ify@1.0.0:
- resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
-
array-includes@3.1.9:
resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
engines: {node: '>= 0.4'}
@@ -3907,10 +3806,6 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
- arrify@1.0.1:
- resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
- engines: {node: '>=0.10.0'}
-
arrify@2.0.1:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'}
@@ -3927,10 +3822,6 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
- astral-regex@2.0.0:
- resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
- engines: {node: '>=8'}
-
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -4157,10 +4048,6 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
- camelcase-keys@6.2.2:
- resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==}
- engines: {node: '>=8'}
-
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
@@ -4256,38 +4143,18 @@ packages:
class-validator@0.14.3:
resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==}
- clean-stack@2.2.0:
- resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
- engines: {node: '>=6'}
-
clean-webpack-plugin@4.0.0:
resolution: {integrity: sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==}
engines: {node: '>=10.0.0'}
peerDependencies:
webpack: '>=4.0.0 <6.0.0'
- cli-cursor@3.1.0:
- resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
- engines: {node: '>=8'}
-
- cli-truncate@2.1.0:
- resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
- engines: {node: '>=8'}
-
- cli-truncate@3.1.0:
- resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
- cliui@8.0.1:
- resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
- engines: {node: '>=12'}
-
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
@@ -4320,9 +4187,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
- colorette@2.0.20:
- resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
-
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -4349,10 +4213,6 @@ packages:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
- commander@9.5.0:
- resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
- engines: {node: ^12.20.0 || >=14}
-
common-tags@1.8.2:
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
engines: {node: '>=4.0.0'}
@@ -4360,9 +4220,6 @@ packages:
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
- compare-func@2.0.0:
- resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
-
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -4386,19 +4243,6 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
- conventional-changelog-angular@5.0.13:
- resolution: {integrity: sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==}
- engines: {node: '>=10'}
-
- conventional-changelog-conventionalcommits@4.6.3:
- resolution: {integrity: sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==}
- engines: {node: '>=10'}
-
- conventional-commits-parser@3.2.4:
- resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
- engines: {node: '>=10'}
- hasBin: true
-
convert-hrtime@3.0.0:
resolution: {integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==}
engines: {node: '>=8'}
@@ -4431,18 +4275,6 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
- cosmiconfig-typescript-loader@2.0.2:
- resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==}
- engines: {node: '>=12', npm: '>=6'}
- peerDependencies:
- '@types/node': '*'
- cosmiconfig: '>=7'
- typescript: '>=3'
-
- cosmiconfig@7.1.0:
- resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
- engines: {node: '>=10'}
-
cosmiconfig@8.3.6:
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
engines: {node: '>=14'}
@@ -4512,10 +4344,6 @@ packages:
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
- dargs@7.0.0:
- resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==}
- engines: {node: '>=8'}
-
data-urls@2.0.0:
resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==}
engines: {node: '>=10'}
@@ -4562,14 +4390,6 @@ packages:
supports-color:
optional: true
- decamelize-keys@1.1.1:
- resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
- engines: {node: '>=0.10.0'}
-
- decamelize@1.2.0:
- resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
- engines: {node: '>=0.10.0'}
-
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -4694,10 +4514,6 @@ packages:
resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==}
engines: {node: '>=20'}
- dot-prop@5.3.0:
- resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
- engines: {node: '>=8'}
-
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -5216,10 +5032,6 @@ packages:
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'}
-
fs-extra@11.1.0:
resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==}
engines: {node: '>=14.14'}
@@ -5304,11 +5116,6 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
- git-raw-commits@2.0.11:
- resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==}
- engines: {node: '>=10'}
- hasBin: true
-
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@@ -5345,10 +5152,6 @@ packages:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
- global-dirs@0.1.1:
- resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
- engines: {node: '>=4'}
-
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -5382,10 +5185,6 @@ packages:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
- hard-rejection@2.1.0:
- resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==}
- engines: {node: '>=6'}
-
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -5426,13 +5225,6 @@ packages:
hls.js@1.6.15:
resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==}
- hosted-git-info@2.8.9:
- resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
-
- hosted-git-info@4.1.0:
- resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
- engines: {node: '>=10'}
-
html-encoding-sniffer@2.0.1:
resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
engines: {node: '>=10'}
@@ -5477,11 +5269,6 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
- husky@7.0.4:
- resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==}
- engines: {node: '>=12'}
- hasBin: true
-
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -5619,10 +5406,6 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
- is-fullwidth-code-point@4.0.0:
- resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
- engines: {node: '>=12'}
-
is-generator-fn@2.1.0:
resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==}
engines: {node: '>=6'}
@@ -5668,10 +5451,6 @@ packages:
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
engines: {node: '>=0.10.0'}
- is-obj@2.0.0:
- resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
- engines: {node: '>=8'}
-
is-path-cwd@2.2.0:
resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==}
engines: {node: '>=6'}
@@ -5688,10 +5467,6 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
- is-plain-obj@1.1.0:
- resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
- engines: {node: '>=0.10.0'}
-
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -5730,10 +5505,6 @@ packages:
resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
engines: {node: '>= 0.4'}
- is-text-path@1.0.1:
- resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==}
- engines: {node: '>=0.10.0'}
-
is-typed-array@1.1.15:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'}
@@ -6043,10 +5814,6 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
- jsonparse@1.3.1:
- resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
- engines: {'0': node >= 0.2.0}
-
jsonpointer@5.0.1:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
@@ -6092,10 +5859,6 @@ packages:
libphonenumber-js@1.12.33:
resolution: {integrity: sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==}
- lilconfig@2.0.5:
- resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==}
- engines: {node: '>=10'}
-
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -6103,20 +5866,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
- lint-staged@12.5.0:
- resolution: {integrity: sha512-BKLUjWDsKquV/JuIcoQW4MSAI3ggwEImF1+sB4zaKvyVx1wBk3FsG7UK9bpnmBTN1pm7EH2BBcMwINJzCRv12g==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
- hasBin: true
-
- listr2@4.0.5:
- resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==}
- engines: {node: '>=12'}
- peerDependencies:
- enquirer: '>= 2.3.0 < 3'
- peerDependenciesMeta:
- enquirer:
- optional: true
-
loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
@@ -6145,10 +5894,6 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
- log-update@4.0.0:
- resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
- engines: {node: '>=10'}
-
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -6199,14 +5944,6 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
- map-obj@1.0.1:
- resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==}
- engines: {node: '>=0.10.0'}
-
- map-obj@4.3.0:
- resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
- engines: {node: '>=8'}
-
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -6300,10 +6037,6 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
- meow@8.1.2:
- resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==}
- engines: {node: '>=10'}
-
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
@@ -6534,10 +6267,6 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
- minimist-options@4.1.0:
- resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==}
- engines: {node: '>= 6'}
-
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -6743,13 +6472,6 @@ packages:
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
- normalize-package-data@2.5.0:
- resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
-
- normalize-package-data@3.0.3:
- resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==}
- engines: {node: '>=10'}
-
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -6877,10 +6599,6 @@ packages:
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
engines: {node: '>=6'}
- p-map@4.0.0:
- resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
- engines: {node: '>=10'}
-
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -7007,11 +6725,6 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
- pidtree@0.5.0:
- resolution: {integrity: sha512-9nxspIM7OpZuhBxPg73Zvyq7j1QMPMPsGKTqRc2XOaFQauDvoNz9fM1Wdkjmeo7l9GXOZiRs97sPkuayl39wjA==}
- engines: {node: '>=0.10'}
- hasBin: true
-
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -7247,14 +6960,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- q@1.5.1:
- resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==}
- engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
- deprecated: |-
- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
-
- (For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
-
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@@ -7265,10 +6970,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
- quick-lru@4.0.1:
- resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
- engines: {node: '>=8'}
-
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -7320,14 +7021,6 @@ packages:
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
- read-pkg-up@7.0.1:
- resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
- engines: {node: '>=8'}
-
- read-pkg@5.2.0:
- resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
- engines: {node: '>=8'}
-
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -7408,10 +7101,6 @@ packages:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
- resolve-global@1.0.0:
- resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==}
- engines: {node: '>=8'}
-
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -7428,10 +7117,6 @@ packages:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
- restore-cursor@3.1.0:
- resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
- engines: {node: '>=8'}
-
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
@@ -7440,9 +7125,6 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rfdc@1.4.1:
- resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
-
rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -7476,9 +7158,6 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
- rxjs@7.8.2:
- resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
-
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -7519,19 +7198,10 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
- semver@5.7.2:
- resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
- hasBin: true
-
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.3.7:
- resolution: {integrity: sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==}
- engines: {node: '>=10'}
- hasBin: true
-
semver@7.5.4:
resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==}
engines: {node: '>=10'}
@@ -7629,18 +7299,6 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
- slice-ansi@3.0.0:
- resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
- engines: {node: '>=8'}
-
- slice-ansi@4.0.0:
- resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
- engines: {node: '>=10'}
-
- slice-ansi@5.0.0:
- resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
- engines: {node: '>=12'}
-
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -7693,21 +7351,6 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
- spdx-correct@3.2.0:
- resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
-
- spdx-exceptions@2.5.0:
- resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
-
- spdx-expression-parse@3.0.1:
- resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
-
- spdx-license-ids@3.0.22:
- resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==}
-
- split2@3.2.2:
- resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==}
-
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -7748,10 +7391,6 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
- string-argv@0.3.2:
- resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
- engines: {node: '>=0.6.19'}
-
string-length@4.0.2:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
@@ -7891,10 +7530,6 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
- supports-color@9.4.0:
- resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==}
- engines: {node: '>=12'}
-
supports-hyperlinks@2.3.0:
resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==}
engines: {node: '>=8'}
@@ -7995,10 +7630,6 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
- text-extensions@1.9.0:
- resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==}
- engines: {node: '>=0.10'}
-
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -8016,12 +7647,6 @@ packages:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
- through2@4.0.2:
- resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
-
- through@2.3.8:
- resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
-
time-span@4.0.0:
resolution: {integrity: sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==}
engines: {node: '>=10'}
@@ -8069,10 +7694,6 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
- trim-newlines@3.0.1:
- resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
- engines: {node: '>=8'}
-
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
@@ -8155,10 +7776,6 @@ packages:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
- type-fest@0.18.1:
- resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==}
- engines: {node: '>=10'}
-
type-fest@0.20.2:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
@@ -8167,14 +7784,6 @@ packages:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
- type-fest@0.6.0:
- resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}
- engines: {node: '>=8'}
-
- type-fest@0.8.1:
- resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
- engines: {node: '>=8'}
-
type-fest@3.13.1:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
@@ -8369,9 +7978,6 @@ packages:
resolution: {integrity: sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==}
engines: {node: '>=10.12.0'}
- validate-npm-package-license@3.0.4:
- resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
-
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
@@ -8592,10 +8198,6 @@ packages:
'@cloudflare/workers-types':
optional: true
- wrap-ansi@6.2.0:
- resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
- engines: {node: '>=8'}
-
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -8694,10 +8296,6 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
- yaml@1.10.2:
- resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
- engines: {node: '>= 6'}
-
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@@ -8707,10 +8305,6 @@ packages:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
- yargs-parser@21.1.1:
- resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
- engines: {node: '>=12'}
-
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -8719,10 +8313,6 @@ packages:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
- yargs@17.7.2:
- resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
- engines: {node: '>=12'}
-
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -10246,7 +9836,7 @@ snapshots:
'@babel/types': 7.28.5
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -10298,7 +9888,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-plugin-utils': 7.27.1
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.11
transitivePeerDependencies:
@@ -11018,7 +10608,7 @@ snapshots:
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.5
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -11052,113 +10642,6 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20260120.0':
optional: true
- '@commitlint/cli@16.3.0':
- dependencies:
- '@commitlint/format': 16.2.1
- '@commitlint/lint': 16.2.4
- '@commitlint/load': 16.3.0
- '@commitlint/read': 16.2.1
- '@commitlint/types': 16.2.1
- lodash: 4.17.21
- resolve-from: 5.0.0
- resolve-global: 1.0.0
- yargs: 17.7.2
- transitivePeerDependencies:
- - '@swc/core'
- - '@swc/wasm'
-
- '@commitlint/config-conventional@16.2.4':
- dependencies:
- conventional-changelog-conventionalcommits: 4.6.3
-
- '@commitlint/config-validator@16.2.1':
- dependencies:
- '@commitlint/types': 16.2.1
- ajv: 6.12.6
-
- '@commitlint/ensure@16.2.1':
- dependencies:
- '@commitlint/types': 16.2.1
- lodash: 4.17.21
-
- '@commitlint/execute-rule@16.2.1': {}
-
- '@commitlint/format@16.2.1':
- dependencies:
- '@commitlint/types': 16.2.1
- chalk: 4.1.2
-
- '@commitlint/is-ignored@16.2.4':
- dependencies:
- '@commitlint/types': 16.2.1
- semver: 7.3.7
-
- '@commitlint/lint@16.2.4':
- dependencies:
- '@commitlint/is-ignored': 16.2.4
- '@commitlint/parse': 16.2.1
- '@commitlint/rules': 16.2.4
- '@commitlint/types': 16.2.1
-
- '@commitlint/load@16.3.0':
- dependencies:
- '@commitlint/config-validator': 16.2.1
- '@commitlint/execute-rule': 16.2.1
- '@commitlint/resolve-extends': 16.2.1
- '@commitlint/types': 16.2.1
- '@types/node': 24.0.3
- chalk: 4.1.2
- cosmiconfig: 7.1.0
- cosmiconfig-typescript-loader: 2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5)
- lodash: 4.17.21
- resolve-from: 5.0.0
- typescript: 4.9.5
- transitivePeerDependencies:
- - '@swc/core'
- - '@swc/wasm'
-
- '@commitlint/message@16.2.1': {}
-
- '@commitlint/parse@16.2.1':
- dependencies:
- '@commitlint/types': 16.2.1
- conventional-changelog-angular: 5.0.13
- conventional-commits-parser: 3.2.4
-
- '@commitlint/read@16.2.1':
- dependencies:
- '@commitlint/top-level': 16.2.1
- '@commitlint/types': 16.2.1
- fs-extra: 10.1.0
- git-raw-commits: 2.0.11
-
- '@commitlint/resolve-extends@16.2.1':
- dependencies:
- '@commitlint/config-validator': 16.2.1
- '@commitlint/types': 16.2.1
- import-fresh: 3.3.1
- lodash: 4.17.21
- resolve-from: 5.0.0
- resolve-global: 1.0.0
-
- '@commitlint/rules@16.2.4':
- dependencies:
- '@commitlint/ensure': 16.2.1
- '@commitlint/message': 16.2.1
- '@commitlint/to-lines': 16.2.1
- '@commitlint/types': 16.2.1
- execa: 5.1.1
-
- '@commitlint/to-lines@16.2.1': {}
-
- '@commitlint/top-level@16.2.1':
- dependencies:
- find-up: 5.0.0
-
- '@commitlint/types@16.2.1':
- dependencies:
- chalk: 4.1.2
-
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -11402,7 +10885,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -11459,7 +10942,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -13094,8 +12577,6 @@ snapshots:
dependencies:
minimatch: 10.1.1
- '@types/minimist@1.2.5': {}
-
'@types/ms@2.1.0': {}
'@types/node-fetch@2.6.13':
@@ -13122,12 +12603,8 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@types/normalize-package-data@2.4.4': {}
-
'@types/nprogress@0.2.3': {}
- '@types/parse-json@4.0.2': {}
-
'@types/pg@8.11.6':
dependencies:
'@types/node': 24.0.3
@@ -13198,7 +12675,7 @@ snapshots:
'@typescript-eslint/scope-manager': 5.62.0
'@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
eslint: 8.57.1
graphemer: 1.4.0
ignore: 5.3.2
@@ -13215,7 +12692,7 @@ snapshots:
'@typescript-eslint/scope-manager': 5.62.0
'@typescript-eslint/types': 5.62.0
'@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5)
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
eslint: 8.57.1
optionalDependencies:
typescript: 4.9.5
@@ -13231,7 +12708,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5)
'@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
eslint: 8.57.1
tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies:
@@ -13245,7 +12722,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 5.62.0
'@typescript-eslint/visitor-keys': 5.62.0
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
globby: 11.1.0
is-glob: 4.0.3
semver: 7.7.3
@@ -13714,11 +13191,6 @@ snapshots:
'@xtuc/long@4.2.2': {}
- JSONStream@1.3.5:
- dependencies:
- jsonparse: 1.3.1
- through: 2.3.8
-
abab@2.0.6: {}
abbrev@3.0.1: {}
@@ -13766,7 +13238,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -13776,11 +13248,6 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
- aggregate-error@3.1.0:
- dependencies:
- clean-stack: 2.2.0
- indent-string: 4.0.0
-
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -13875,8 +13342,6 @@ snapshots:
array-differ@3.0.0: {}
- array-ify@1.0.0: {}
-
array-includes@3.1.9:
dependencies:
call-bind: 1.0.8
@@ -13947,8 +13412,6 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
- arrify@1.0.1: {}
-
arrify@2.0.1: {}
artplayer-plugin-danmuku@5.2.0: {}
@@ -13967,8 +13430,6 @@ snapshots:
ast-types-flow@0.0.8: {}
- astral-regex@2.0.0: {}
-
async-function@1.0.0: {}
async-listen@1.2.0: {}
@@ -14139,7 +13600,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -14228,12 +13689,6 @@ snapshots:
camelcase-css@2.0.1: {}
- camelcase-keys@6.2.2:
- dependencies:
- camelcase: 5.3.1
- map-obj: 4.3.0
- quick-lru: 4.0.1
-
camelcase@5.3.1: {}
camelcase@6.3.0: {}
@@ -14329,27 +13784,11 @@ snapshots:
libphonenumber-js: 1.12.33
validator: 13.15.26
- clean-stack@2.2.0: {}
-
clean-webpack-plugin@4.0.0(webpack@5.104.1):
dependencies:
del: 4.1.1
webpack: 5.104.1
- cli-cursor@3.1.0:
- dependencies:
- restore-cursor: 3.1.0
-
- cli-truncate@2.1.0:
- dependencies:
- slice-ansi: 3.0.0
- string-width: 4.2.3
-
- cli-truncate@3.1.0:
- dependencies:
- slice-ansi: 5.0.0
- string-width: 5.1.2
-
client-only@0.0.1: {}
cliui@7.0.4:
@@ -14358,12 +13797,6 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
- cliui@8.0.1:
- dependencies:
- string-width: 4.2.3
- strip-ansi: 6.0.1
- wrap-ansi: 7.0.0
-
cliui@9.0.1:
dependencies:
string-width: 7.2.0
@@ -14398,8 +13831,6 @@ snapshots:
color-name@1.1.4: {}
- colorette@2.0.20: {}
-
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -14416,17 +13847,10 @@ snapshots:
commander@7.2.0: {}
- commander@9.5.0: {}
-
common-tags@1.8.2: {}
commondir@1.0.1: {}
- compare-func@2.0.0:
- dependencies:
- array-ify: 1.0.0
- dot-prop: 5.3.0
-
concat-map@0.0.1: {}
conf@15.0.2:
@@ -14449,26 +13873,6 @@ snapshots:
content-type@1.0.5: {}
- conventional-changelog-angular@5.0.13:
- dependencies:
- compare-func: 2.0.0
- q: 1.5.1
-
- conventional-changelog-conventionalcommits@4.6.3:
- dependencies:
- compare-func: 2.0.0
- lodash: 4.17.21
- q: 1.5.1
-
- conventional-commits-parser@3.2.4:
- dependencies:
- JSONStream: 1.3.5
- is-text-path: 1.0.1
- lodash: 4.17.21
- meow: 8.1.2
- split2: 3.2.2
- through2: 4.0.2
-
convert-hrtime@3.0.0: {}
convert-source-map@1.9.0: {}
@@ -14492,24 +13896,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
- cosmiconfig-typescript-loader@2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5):
- dependencies:
- '@types/node': 24.0.3
- cosmiconfig: 7.1.0
- ts-node: 10.9.2(@types/node@24.0.3)(typescript@4.9.5)
- typescript: 4.9.5
- transitivePeerDependencies:
- - '@swc/core'
- - '@swc/wasm'
-
- cosmiconfig@7.1.0:
- dependencies:
- '@types/parse-json': 4.0.2
- import-fresh: 3.3.1
- parse-json: 5.2.0
- path-type: 4.0.0
- yaml: 1.10.2
-
cosmiconfig@8.3.6(typescript@4.9.5):
dependencies:
import-fresh: 3.3.1
@@ -14573,8 +13959,6 @@ snapshots:
damerau-levenshtein@1.0.8: {}
- dargs@7.0.0: {}
-
data-urls@2.0.0:
dependencies:
abab: 2.0.6
@@ -14611,18 +13995,9 @@ snapshots:
dependencies:
ms: 2.1.2
- debug@4.4.3(supports-color@9.4.0):
+ debug@4.4.3:
dependencies:
ms: 2.1.3
- optionalDependencies:
- supports-color: 9.4.0
-
- decamelize-keys@1.1.1:
- dependencies:
- decamelize: 1.2.0
- map-obj: 1.0.1
-
- decamelize@1.2.0: {}
decimal.js@10.6.0: {}
@@ -14737,10 +14112,6 @@ snapshots:
dependencies:
type-fest: 5.3.1
- dot-prop@5.3.0:
- dependencies:
- is-obj: 2.0.0
-
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -14808,7 +14179,7 @@ snapshots:
engine.io-client@6.6.4(bufferutil@4.1.0):
dependencies:
'@socket.io/component-emitter': 3.1.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3(bufferutil@4.1.0)
xmlhttprequest-ssl: 2.1.2
@@ -14827,7 +14198,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.5
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3(bufferutil@4.1.0)
transitivePeerDependencies:
@@ -15074,7 +14445,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
eslint: 8.57.1
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
@@ -15216,7 +14587,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -15335,7 +14706,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -15418,7 +14789,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -15505,12 +14876,6 @@ snapshots:
fs-constants@1.0.0: {}
- fs-extra@10.1.0:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.0
- universalify: 2.0.1
-
fs-extra@11.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -15596,14 +14961,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
- git-raw-commits@2.0.11:
- dependencies:
- dargs: 7.0.0
- lodash: 4.17.21
- meow: 8.1.2
- split2: 3.2.2
- through2: 4.0.2
-
github-from-package@0.0.0: {}
glob-parent@5.1.2:
@@ -15655,10 +15012,6 @@ snapshots:
minipass: 4.2.8
path-scurry: 1.11.1
- global-dirs@0.1.1:
- dependencies:
- ini: 1.3.8
-
global@4.4.0:
dependencies:
min-document: 2.19.2
@@ -15700,8 +15053,6 @@ snapshots:
dependencies:
duplexer: 0.1.2
- hard-rejection@2.1.0: {}
-
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -15752,12 +15103,6 @@ snapshots:
hls.js@1.6.15: {}
- hosted-git-info@2.8.9: {}
-
- hosted-git-info@4.1.0:
- dependencies:
- lru-cache: 6.0.0
-
html-encoding-sniffer@2.0.1:
dependencies:
whatwg-encoding: 1.0.5
@@ -15793,21 +15138,21 @@ snapshots:
dependencies:
'@tootallnate/once': 1.1.2
agent-base: 6.0.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -15819,8 +15164,6 @@ snapshots:
dependencies:
ms: 2.1.3
- husky@7.0.4: {}
-
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -15955,8 +15298,6 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
- is-fullwidth-code-point@4.0.0: {}
-
is-generator-fn@2.1.0: {}
is-generator-function@1.1.2:
@@ -15995,8 +15336,6 @@ snapshots:
is-obj@1.0.1: {}
- is-obj@2.0.0: {}
-
is-path-cwd@2.2.0: {}
is-path-in-cwd@2.1.0:
@@ -16009,8 +15348,6 @@ snapshots:
is-path-inside@3.0.3: {}
- is-plain-obj@1.1.0: {}
-
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -16045,10 +15382,6 @@ snapshots:
has-symbols: 1.1.0
safe-regex-test: 1.1.0
- is-text-path@1.0.1:
- dependencies:
- text-extensions: 1.9.0
-
is-typed-array@1.1.15:
dependencies:
which-typed-array: 1.1.19
@@ -16092,7 +15425,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@@ -16646,8 +15979,6 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
- jsonparse@1.3.1: {}
-
jsonpointer@5.0.1: {}
jsx-ast-utils@3.3.5:
@@ -16687,44 +16018,10 @@ snapshots:
libphonenumber-js@1.12.33: {}
- lilconfig@2.0.5: {}
-
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
- lint-staged@12.5.0(enquirer@2.4.1):
- dependencies:
- cli-truncate: 3.1.0
- colorette: 2.0.20
- commander: 9.5.0
- debug: 4.4.3(supports-color@9.4.0)
- execa: 5.1.1
- lilconfig: 2.0.5
- listr2: 4.0.5(enquirer@2.4.1)
- micromatch: 4.0.8
- normalize-path: 3.0.0
- object-inspect: 1.13.4
- pidtree: 0.5.0
- string-argv: 0.3.2
- supports-color: 9.4.0
- yaml: 1.10.2
- transitivePeerDependencies:
- - enquirer
-
- listr2@4.0.5(enquirer@2.4.1):
- dependencies:
- cli-truncate: 2.1.0
- colorette: 2.0.20
- log-update: 4.0.0
- p-map: 4.0.0
- rfdc: 1.4.1
- rxjs: 7.8.2
- through: 2.3.8
- wrap-ansi: 7.0.0
- optionalDependencies:
- enquirer: 2.4.1
-
loader-runner@4.3.1: {}
loader-utils@2.0.4:
@@ -16749,13 +16046,6 @@ snapshots:
lodash@4.17.21: {}
- log-update@4.0.0:
- dependencies:
- ansi-escapes: 4.3.2
- cli-cursor: 3.1.0
- slice-ansi: 4.0.0
- wrap-ansi: 6.2.0
-
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -16802,10 +16092,6 @@ snapshots:
dependencies:
tmpl: 1.0.5
- map-obj@1.0.1: {}
-
- map-obj@4.3.0: {}
-
markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {}
@@ -17010,20 +16296,6 @@ snapshots:
media-typer@1.1.0: {}
- meow@8.1.2:
- dependencies:
- '@types/minimist': 1.2.5
- camelcase-keys: 6.2.2
- decamelize-keys: 1.1.1
- hard-rejection: 2.1.0
- minimist-options: 4.1.0
- normalize-package-data: 3.0.3
- read-pkg-up: 7.0.1
- redent: 3.0.0
- trim-newlines: 3.0.1
- type-fest: 0.18.1
- yargs-parser: 20.2.9
-
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
@@ -17319,7 +16591,7 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.12
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
decode-named-character-reference: 1.2.0
micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0
@@ -17341,7 +16613,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -17424,12 +16696,6 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
- minimist-options@4.1.0:
- dependencies:
- arrify: 1.0.1
- is-plain-obj: 1.1.0
- kind-of: 6.0.3
-
minimist@1.2.8: {}
minipass@3.3.6:
@@ -17604,20 +16870,6 @@ snapshots:
dependencies:
abbrev: 3.0.1
- normalize-package-data@2.5.0:
- dependencies:
- hosted-git-info: 2.8.9
- resolve: 1.22.11
- semver: 5.7.2
- validate-npm-package-license: 3.0.4
-
- normalize-package-data@3.0.3:
- dependencies:
- hosted-git-info: 4.1.0
- is-core-module: 2.16.1
- semver: 7.7.3
- validate-npm-package-license: 3.0.4
-
normalize-path@3.0.0: {}
npm-run-path@4.0.1:
@@ -17755,10 +17007,6 @@ snapshots:
p-map@2.1.0: {}
- p-map@4.0.0:
- dependencies:
- aggregate-error: 3.1.0
-
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
@@ -17873,8 +17121,6 @@ snapshots:
picomatch@4.0.3: {}
- pidtree@0.5.0: {}
-
pify@2.3.0: {}
pify@4.0.1: {}
@@ -18041,8 +17287,6 @@ snapshots:
punycode@2.3.1: {}
- q@1.5.1: {}
-
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@@ -18051,8 +17295,6 @@ snapshots:
queue-microtask@1.2.3: {}
- quick-lru@4.0.1: {}
-
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -18122,19 +17364,6 @@ snapshots:
dependencies:
pify: 2.3.0
- read-pkg-up@7.0.1:
- dependencies:
- find-up: 4.1.0
- read-pkg: 5.2.0
- type-fest: 0.8.1
-
- read-pkg@5.2.0:
- dependencies:
- '@types/normalize-package-data': 2.4.4
- normalize-package-data: 2.5.0
- parse-json: 5.2.0
- type-fest: 0.6.0
-
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -18244,10 +17473,6 @@ snapshots:
resolve-from@5.0.0: {}
- resolve-global@1.0.0:
- dependencies:
- global-dirs: 0.1.1
-
resolve-pkg-maps@1.0.0: {}
resolve.exports@1.1.1: {}
@@ -18264,17 +17489,10 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
- restore-cursor@3.1.0:
- dependencies:
- onetime: 5.1.2
- signal-exit: 3.0.7
-
retry@0.13.1: {}
reusify@1.1.0: {}
- rfdc@1.4.1: {}
-
rimraf@2.7.1:
dependencies:
glob: 7.2.3
@@ -18316,7 +17534,7 @@ snapshots:
router@2.2.0:
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -18328,10 +17546,6 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
- rxjs@7.8.2:
- dependencies:
- tslib: 2.8.1
-
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -18382,14 +17596,8 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.17.1)
ajv-keywords: 5.1.0(ajv@8.17.1)
- semver@5.7.2: {}
-
semver@6.3.1: {}
- semver@7.3.7:
- dependencies:
- lru-cache: 6.0.0
-
semver@7.5.4:
dependencies:
lru-cache: 6.0.0
@@ -18398,7 +17606,7 @@ snapshots:
send@1.2.1:
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -18540,23 +17748,6 @@ snapshots:
slash@3.0.0: {}
- slice-ansi@3.0.0:
- dependencies:
- ansi-styles: 4.3.0
- astral-regex: 2.0.0
- is-fullwidth-code-point: 3.0.0
-
- slice-ansi@4.0.0:
- dependencies:
- ansi-styles: 4.3.0
- astral-regex: 2.0.0
- is-fullwidth-code-point: 3.0.0
-
- slice-ansi@5.0.0:
- dependencies:
- ansi-styles: 6.2.3
- is-fullwidth-code-point: 4.0.0
-
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -18564,7 +17755,7 @@ snapshots:
socket.io-adapter@2.5.6(bufferutil@4.1.0):
dependencies:
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
ws: 8.18.3(bufferutil@4.1.0)
transitivePeerDependencies:
- bufferutil
@@ -18574,7 +17765,7 @@ snapshots:
socket.io-client@4.8.3(bufferutil@4.1.0):
dependencies:
'@socket.io/component-emitter': 3.1.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
engine.io-client: 6.6.4(bufferutil@4.1.0)
socket.io-parser: 4.2.5
transitivePeerDependencies:
@@ -18585,7 +17776,7 @@ snapshots:
socket.io-parser@4.2.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -18594,7 +17785,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
- debug: 4.4.3(supports-color@9.4.0)
+ debug: 4.4.3
engine.io: 6.6.5(bufferutil@4.1.0)
socket.io-adapter: 2.5.6(bufferutil@4.1.0)
socket.io-parser: 4.2.5
@@ -18628,24 +17819,6 @@ snapshots:
space-separated-tokens@2.0.2: {}
- spdx-correct@3.2.0:
- dependencies:
- spdx-expression-parse: 3.0.1
- spdx-license-ids: 3.0.22
-
- spdx-exceptions@2.5.0: {}
-
- spdx-expression-parse@3.0.1:
- dependencies:
- spdx-exceptions: 2.5.0
- spdx-license-ids: 3.0.22
-
- spdx-license-ids@3.0.22: {}
-
- split2@3.2.2:
- dependencies:
- readable-stream: 3.6.2
-
sprintf-js@1.0.3: {}
srvx@0.8.9:
@@ -18681,8 +17854,6 @@ snapshots:
streamsearch@1.1.0: {}
- string-argv@0.3.2: {}
-
string-length@4.0.2:
dependencies:
char-regex: 1.0.2
@@ -18846,8 +18017,6 @@ snapshots:
dependencies:
has-flag: 4.0.0
- supports-color@9.4.0: {}
-
supports-hyperlinks@2.3.0:
dependencies:
has-flag: 4.0.0
@@ -18982,8 +18151,6 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.2
- text-extensions@1.9.0: {}
-
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -18998,12 +18165,6 @@ snapshots:
throttleit@2.1.0: {}
- through2@4.0.2:
- dependencies:
- readable-stream: 3.6.2
-
- through@2.3.8: {}
-
time-span@4.0.0:
dependencies:
convert-hrtime: 3.0.0
@@ -19046,8 +18207,6 @@ snapshots:
trim-lines@3.0.1: {}
- trim-newlines@3.0.1: {}
-
trough@2.2.0: {}
ts-interface-checker@0.1.13: {}
@@ -19092,6 +18251,7 @@ snapshots:
typescript: 4.9.5
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
+ optional: true
ts-toolbelt@6.15.5: {}
@@ -19136,16 +18296,10 @@ snapshots:
type-fest@0.16.0: {}
- type-fest@0.18.1: {}
-
type-fest@0.20.2: {}
type-fest@0.21.3: {}
- type-fest@0.6.0: {}
-
- type-fest@0.8.1: {}
-
type-fest@3.13.1: {}
type-fest@5.3.1:
@@ -19389,11 +18543,6 @@ snapshots:
convert-source-map: 1.9.0
source-map: 0.7.6
- validate-npm-package-license@3.0.4:
- dependencies:
- spdx-correct: 3.2.0
- spdx-expression-parse: 3.0.1
-
validator@13.15.26: {}
vary@1.1.2: {}
@@ -19770,12 +18919,6 @@ snapshots:
- bufferutil
- utf-8-validate
- wrap-ansi@6.2.0:
- dependencies:
- ansi-styles: 4.3.0
- string-width: 4.2.3
- strip-ansi: 6.0.1
-
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -19846,14 +18989,10 @@ snapshots:
yallist@5.0.0: {}
- yaml@1.10.2: {}
-
yaml@2.8.2: {}
yargs-parser@20.2.9: {}
- yargs-parser@21.1.1: {}
-
yargs-parser@22.0.0: {}
yargs@16.2.0:
@@ -19866,16 +19005,6 @@ snapshots:
y18n: 5.0.8
yargs-parser: 20.2.9
- yargs@17.7.2:
- dependencies:
- cliui: 8.0.1
- escalade: 3.2.0
- get-caller-file: 2.0.5
- require-directory: 2.1.1
- string-width: 4.2.3
- y18n: 5.0.8
- yargs-parser: 21.1.1
-
yargs@18.0.0:
dependencies:
cliui: 9.0.1
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 07dbe5a..52c4af0 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -45,7 +45,7 @@ import {
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
-import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
@@ -317,6 +317,18 @@ const useLoadingState = () => {
return { loadingStates, setLoading, isLoading, withLoading };
};
+interface StandaloneSourceScript {
+ id: string;
+ key: string;
+ name: string;
+ description?: string;
+ enabled: boolean;
+ version: string;
+ code: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
// 新增站点配置类型
interface SiteConfig {
SiteName: string;
@@ -340,6 +352,10 @@ interface SiteConfig {
PansouUsername?: string;
PansouPassword?: string;
PansouKeywordBlocklist?: string;
+ MagnetProxy?: string;
+ MagnetMikanReverseProxy?: string;
+ MagnetDmhyReverseProxy?: string;
+ MagnetAcgripReverseProxy?: string;
EnableComments: boolean;
EnableRegistration?: boolean;
RegistrationRequireTurnstile?: boolean;
@@ -5489,6 +5505,7 @@ const VideoSourceConfig = ({
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
+ onConfirm={alertModal.onConfirm}
/>
{/* 批量操作确认弹窗 */}
@@ -5953,6 +5970,540 @@ const CategoryConfig = ({
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
+ onConfirm={alertModal.onConfirm}
+ />
+
+ );
+};
+
+const VideoSourceScriptLab = () => {
+ const { alertModal, showAlert, hideAlert } = useAlertModal();
+ const { isLoading, withLoading } = useLoadingState();
+ const [scripts, setScripts] = useState([]);
+ const [loadingScripts, setLoadingScripts] = useState(true);
+ const [template, setTemplate] = useState('');
+ const [selectedScriptId, setSelectedScriptId] = useState(null);
+ const [editor, setEditor] = useState<{
+ id?: string;
+ key: string;
+ name: string;
+ description: string;
+ code: string;
+ enabled: boolean;
+ version?: string;
+ updatedAt?: number;
+ }>({
+ key: '',
+ name: '',
+ description: '',
+ code: '',
+ enabled: true,
+ });
+ const [testHook, setTestHook] = useState<'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'>('getSources');
+ const [testPayload, setTestPayload] = useState(
+ JSON.stringify({}, null, 2)
+ );
+ const [testOutput, setTestOutput] = useState('');
+ const importInputRef = useRef(null);
+
+ const applyEditorFromScript = (script: StandaloneSourceScript | null) => {
+ if (!script) {
+ setEditor({
+ key: '',
+ name: '',
+ description: '',
+ code: template,
+ enabled: true,
+ });
+ setSelectedScriptId(null);
+ return;
+ }
+
+ setEditor({
+ id: script.id,
+ key: script.key,
+ name: script.name,
+ description: script.description || '',
+ code: script.code,
+ enabled: script.enabled,
+ version: script.version,
+ updatedAt: script.updatedAt,
+ });
+ setSelectedScriptId(script.id);
+ };
+
+ const loadScripts = async (preferId?: string | null) => {
+ setLoadingScripts(true);
+ try {
+ const response = await fetch('/api/admin/source-script', {
+ cache: 'no-store',
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || '加载脚本失败');
+ }
+
+ const nextScripts = (data.items || []) as StandaloneSourceScript[];
+ setScripts(nextScripts);
+ setTemplate(data.template || '');
+
+ const targetId =
+ preferId !== undefined
+ ? preferId
+ : selectedScriptId || nextScripts[0]?.id || null;
+
+ const selected = nextScripts.find((item) => item.id === targetId) || null;
+ if (selected) {
+ applyEditorFromScript(selected);
+ } else {
+ setEditor({
+ key: '',
+ name: '',
+ description: '',
+ code: data.template || '',
+ enabled: true,
+ });
+ setSelectedScriptId(null);
+ }
+ } catch (error) {
+ showError(error instanceof Error ? error.message : '加载脚本失败', showAlert);
+ } finally {
+ setLoadingScripts(false);
+ }
+ };
+
+ useEffect(() => {
+ loadScripts();
+ }, []);
+
+ const handleCreateNew = () => {
+ setSelectedScriptId(null);
+ setEditor({
+ key: '',
+ name: '',
+ description: '',
+ code: template,
+ enabled: true,
+ });
+ setTestOutput('');
+ };
+
+ const handleExportCurrent = () => {
+ if (!editor.key || !editor.name || !editor.code) {
+ showError('当前没有可导出的脚本', showAlert);
+ return;
+ }
+
+ const payload = {
+ key: editor.key,
+ name: editor.name,
+ description: editor.description,
+ code: editor.code,
+ enabled: editor.enabled,
+ };
+
+ const blob = new Blob([JSON.stringify(payload, null, 2)], {
+ type: 'application/json',
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${editor.key}.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImportFile = async (
+ event: React.ChangeEvent
+ ) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const raw = await file.text();
+ const parsed = JSON.parse(raw);
+ const items = Array.isArray(parsed) ? parsed : [parsed];
+
+ await withLoading('importSourceScript', async () => {
+ const response = await fetch('/api/admin/source-script', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'import',
+ items,
+ }),
+ });
+
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || '导入失败');
+ }
+
+ showSuccess(`已导入 ${data.items?.length || 0} 个脚本`, showAlert);
+ await loadScripts(data.items?.[0]?.id || null);
+ });
+ } catch (error) {
+ showError(error instanceof Error ? error.message : '导入失败', showAlert);
+ } finally {
+ event.target.value = '';
+ }
+ };
+
+ const handleSave = async () => {
+ if (!editor.key || !editor.name || !editor.code) {
+ showError('请填写脚本 Key、名称和代码', showAlert);
+ return;
+ }
+
+ await withLoading('saveSourceScript', async () => {
+ const response = await fetch('/api/admin/source-script', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'save',
+ id: editor.id,
+ key: editor.key,
+ name: editor.name,
+ description: editor.description,
+ code: editor.code,
+ enabled: editor.enabled,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || '保存失败');
+ }
+
+ showSuccess('脚本已保存', showAlert);
+ await loadScripts(data.item?.id || editor.id || null);
+ }).catch((error) => {
+ showError(error instanceof Error ? error.message : '保存失败', showAlert);
+ });
+ };
+
+ const handleDelete = async () => {
+ if (!editor.id) {
+ handleCreateNew();
+ return;
+ }
+
+ showAlert({
+ type: 'warning',
+ title: '删除脚本',
+ message: `确定要删除脚本 "${editor.name}" 吗?`,
+ showConfirm: true,
+ onConfirm: async () => {
+ hideAlert();
+ await withLoading('deleteSourceScript', async () => {
+ const response = await fetch('/api/admin/source-script', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'delete',
+ id: editor.id,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || '删除失败');
+ }
+ showSuccess('脚本已删除', showAlert);
+ await loadScripts(null);
+ }).catch((error) => {
+ showError(error instanceof Error ? error.message : '删除失败', showAlert);
+ });
+ },
+ });
+ };
+
+ const handleToggleEnabled = async (id: string) => {
+ await withLoading(`toggleSourceScript_${id}`, async () => {
+ const response = await fetch('/api/admin/source-script', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'toggle_enabled',
+ id,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || '更新失败');
+ }
+ await loadScripts(id);
+ }).catch((error) => {
+ showError(error instanceof Error ? error.message : '更新失败', showAlert);
+ });
+ };
+
+ const handleTest = async () => {
+ let payload = {};
+ try {
+ payload = testPayload.trim() ? JSON.parse(testPayload) : {};
+ } catch {
+ showError('测试输入必须是合法 JSON', showAlert);
+ return;
+ }
+
+ await withLoading('testSourceScript', async () => {
+ const response = await fetch('/api/admin/source-script', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'test',
+ key: editor.key || 'test-script',
+ name: editor.name || '测试脚本',
+ code: editor.code,
+ hook: testHook,
+ payload,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+ setTestOutput(JSON.stringify(data, null, 2));
+ if (!response.ok) {
+ throw new Error(data.error || data.message || '测试失败');
+ }
+ showSuccess('测试执行完成', showAlert);
+ }).catch((error) => {
+ showError(error instanceof Error ? error.message : '测试失败', showAlert);
+ });
+ };
+
+ useEffect(() => {
+ setTestPayload(
+ testHook === 'getSources'
+ ? JSON.stringify({}, null, 2)
+ : testHook === 'search'
+ ? JSON.stringify({ keyword: '凡人修仙传', page: 1, sourceId: 'main' }, null, 2)
+ : testHook === 'recommend'
+ ? JSON.stringify({ page: 1 }, null, 2)
+ : testHook === 'detail'
+ ? JSON.stringify({ id: 'demo-id', sourceId: 'main' }, null, 2)
+ : JSON.stringify(
+ {
+ sourceId: 'main',
+ playUrl: 'https://example.com/video.m3u8',
+ episodeIndex: 0,
+ },
+ null,
+ 2
+ )
+ );
+ }, [testHook]);
+
+ return (
+
+
+
+
+
+ 脚本列表
+
+
+
+
+
+
+
+
+
+
+ {loadingScripts ? (
+
加载中...
+ ) : scripts.length === 0 ? (
+
+ 还没有脚本,点右上角新建一个。
+
+ ) : (
+ scripts.map((script) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
);
@@ -7374,12 +7925,17 @@ const SiteConfigComponent = ({
DanmakuApiToken: '87654321',
TMDBApiKey: '',
TMDBProxy: '',
+ TMDBReverseProxy: '',
BannerDataSource: 'Douban',
RecommendationDataSource: 'Mixed',
PansouApiUrl: '',
PansouUsername: '',
PansouPassword: '',
PansouKeywordBlocklist: '',
+ MagnetProxy: '',
+ MagnetMikanReverseProxy: '',
+ MagnetDmhyReverseProxy: '',
+ MagnetAcgripReverseProxy: '',
EnableComments: false,
EnableRegistration: false,
RegistrationRequireTurnstile: false,
@@ -7465,12 +8021,17 @@ const SiteConfigComponent = ({
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
TMDBProxy: config.SiteConfig.TMDBProxy || '',
+ TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban',
RecommendationDataSource: config.SiteConfig.RecommendationDataSource || 'Mixed',
PansouApiUrl: config.SiteConfig.PansouApiUrl || '',
PansouUsername: config.SiteConfig.PansouUsername || '',
PansouPassword: config.SiteConfig.PansouPassword || '',
PansouKeywordBlocklist: config.SiteConfig.PansouKeywordBlocklist || '',
+ MagnetProxy: config.SiteConfig.MagnetProxy || '',
+ MagnetMikanReverseProxy: config.SiteConfig.MagnetMikanReverseProxy || '',
+ MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
+ MagnetAcgripReverseProxy: config.SiteConfig.MagnetAcgripReverseProxy || '',
EnableComments: config.SiteConfig.EnableComments || false,
});
}
@@ -7943,334 +8504,442 @@ const SiteConfigComponent = ({
- {/* 轮播图数据源 */}
-
-
-
-
- 选择首页轮播图的数据来源
-
-
+
+
+ 数据源配置
+
+
+ {/* 轮播图数据源 */}
+
+
+
+
+ 选择首页轮播图的数据来源
+
+
- {/* 更多推荐数据源 */}
-
-
-
-
- 选择详情页"更多推荐"的数据来源。混合模式会根据豆瓣ID和评论开关自动切换数据源
-
-
+ {/* 更多推荐数据源 */}
+
+
+
+
+ 选择详情页"更多推荐"的数据来源。混合模式会根据豆瓣ID和评论开关自动切换数据源
+
+
+
+
{/* 弹幕 API 配置 */}
-
-
+
+
弹幕配置
-
+
+
+ {/* 弹幕 API 地址 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ DanmakuApiBase: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考
+
+ danmu_api
+
+
+
- {/* 弹幕 API 地址 */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- DanmakuApiBase: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考
-
- danmu_api
-
-
+ {/* 弹幕 API Token */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ DanmakuApiToken: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 弹幕服务器的访问令牌,默认为 87654321
+
+
-
- {/* 弹幕 API Token */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- DanmakuApiToken: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 弹幕服务器的访问令牌,默认为 87654321
-
-
-
+
{/* TMDB 配置 */}
-
-
+
+
TMDB 配置
-
-
- {/* TMDB API Key */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- TMDBApiKey: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 配置后首页将显示 TMDB 即将上映电影。支持配置多个 API Key(用英文逗号分隔)以实现轮询,避免单个 Key 请求限制。获取 API Key 请访问{' '}
-
- TMDB API 设置页面
-
+
+
+
+ 由于国内网络环境限制,TMDB 服务通常需要配置代理后才能正常使用。
-
+ {/* TMDB API Key */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ TMDBApiKey: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置后首页将显示 TMDB 即将上映电影。支持配置多个 API Key(用英文逗号分隔)以实现轮询,避免单个 Key 请求限制。获取 API Key 请访问{' '}
+
+ TMDB API 设置页面
+
+
+
- {/* TMDB Proxy */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- TMDBProxy: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 配置代理服务器地址,用于访问 TMDB API(可选)
-
-
+ {/* TMDB Proxy */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ TMDBProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置代理服务器地址,用于访问 TMDB API(可选)
+
+
- {/* TMDB Reverse Proxy */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- TMDBReverseProxy: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 配置 TMDB 反向代理 Base URL(可选)
-
+ {/* TMDB Reverse Proxy */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ TMDBReverseProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置 TMDB 反向代理 Base URL(可选)
+
+
-
+
+
+
+
+ 磁链配置
+
+
+
+ 由于国内网络环境限制,部分磁链搜索站点通常需要配置代理后才能正常访问。
+
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ MagnetProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 用于访问磁链搜索站点的系统代理。Cloudflare 部署环境下不会使用该代理。
+
+
+
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ MagnetMikanReverseProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置后将使用该地址替代默认的 Mikan 域名进行请求。
+
+
+
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ MagnetDmhyReverseProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置后将使用该地址替代默认的动漫花园域名进行请求。
+
+
+
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ MagnetAcgripReverseProxy: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置后将使用该地址替代默认的 ACG.RIP 域名进行请求。
+
+
+
+
{/* Pansou 配置 */}
-
-
+
+
Pansou 网盘搜索配置
-
+
+
+ {/* Pansou API 地址 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ PansouApiUrl: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置 Pansou 服务器地址,用于网盘资源搜索。项目地址:{' '}
+
+ https://github.com/fish2018/pansou
+
+
+
- {/* Pansou API 地址 */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- PansouApiUrl: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 配置 Pansou 服务器地址,用于网盘资源搜索。项目地址:{' '}
-
- https://github.com/fish2018/pansou
-
-
-
+ {/* Pansou 账号 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ PansouUsername: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 如果 Pansou 服务启用了认证功能,需要提供账号密码
+
+
- {/* Pansou 账号 */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- PansouUsername: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 如果 Pansou 服务启用了认证功能,需要提供账号密码
-
-
+ {/* Pansou 密码 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ PansouPassword: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 配置账号密码后,系统会自动登录并缓存 Token
+
+
- {/* Pansou 密码 */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- PansouPassword: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 配置账号密码后,系统会自动登录并缓存 Token
-
+ {/* 关键词屏蔽 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ PansouKeywordBlocklist: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 设置后会过滤包含这些关键词的搜索结果
+
+
-
- {/* 关键词屏蔽 */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- PansouKeywordBlocklist: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 设置后会过滤包含这些关键词的搜索结果
-
-
-
+
{/* 评论功能配置 */}
-
-
+
+
评论配置
-
-
- {/* 开启评论与相似推荐 */}
-
-
-
-
handleCommentsToggle(!siteSettings.EnableComments)}
- 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.EnableComments
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
+
+ {/* 开启评论与相似推荐 */}
+
+
+
+ handleCommentsToggle(!siteSettings.EnableComments)}
+ 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.EnableComments
- ? buttonStyles.toggleThumbOn
- : buttonStyles.toggleThumbOff
+ ? buttonStyles.toggleOn
+ : buttonStyles.toggleOff
}`}
- />
-
+ >
+
+
+
+
+ 开启后将显示豆瓣评论与相似推荐。评论为逆向抓取,请自行承担责任。
+
-
- 开启后将显示豆瓣评论与相似推荐。评论为逆向抓取,请自行承担责任。
-
-
+
{/* 操作按钮 */}
@@ -11931,6 +12600,7 @@ function AdminPageClient() {
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
+ sourceScriptLab: false,
mediaLibrary: false,
openListConfig: false,
embyConfig: false,
@@ -12306,6 +12976,17 @@ function AdminPageClient() {
+
+ }
+ isExpanded={expandedTabs.sourceScriptLab}
+ onToggle={() => toggleTab('sourceScriptLab')}
+ >
+
+
+
{/* 电视直播源配置标签 */}
(null);
+ const initialUrlSourceRef = useRef(searchParams.get('source') || '');
+
+ const [sources, setSources] = useState([]);
+ const [selectedSource, setSelectedSource] = useState('');
+ const [videos, setVideos] = useState([]);
+ const [isLoadingSources, setIsLoadingSources] = useState(true);
+ const [isLoadingVideos, setIsLoadingVideos] = useState(false);
+ const [error, setError] = useState('');
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const initializedRef = useRef(false);
+ const hasSyncedUrlRef = useRef(false);
+
+ useEffect(() => {
+ const fetchSources = async () => {
+ setIsLoadingSources(true);
+ try {
+ const response = await fetch('/api/advanced-recommendation/sources');
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || '获取脚本源失败');
+ }
+
+ const nextSources: ScriptSourceOption[] = Array.isArray(data.sources)
+ ? data.sources
+ : [];
+ setSources(nextSources);
+
+ const initialSource =
+ nextSources.find((item) => item.key === initialUrlSourceRef.current)
+ ?.key ||
+ nextSources[0]?.key ||
+ '';
+
+ setSelectedSource(initialSource);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '获取脚本源失败');
+ } finally {
+ setIsLoadingSources(false);
+ initializedRef.current = true;
+ }
+ };
+
+ fetchSources();
+ }, []);
+
+ useEffect(() => {
+ if (!initializedRef.current || !selectedSource) return;
+ if (!hasSyncedUrlRef.current) {
+ hasSyncedUrlRef.current = true;
+ if (initialUrlSourceRef.current === selectedSource) return;
+ }
+
+ const params = new URLSearchParams();
+ params.set('source', selectedSource);
+ router.replace(`/advanced-recommendation?${params.toString()}`, {
+ scroll: false,
+ });
+ }, [selectedSource, router]);
+
+ useEffect(() => {
+ if (!selectedSource) return;
+
+ setVideos([]);
+ setPage(1);
+ setHasMore(true);
+ setError('');
+ }, [selectedSource]);
+
+ useEffect(() => {
+ if (!selectedSource) return;
+
+ const fetchVideos = async () => {
+ setIsLoadingVideos(true);
+ try {
+ const response = await fetch(
+ `/api/advanced-recommendation/videos?source=${encodeURIComponent(selectedSource)}&page=${page}`
+ );
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || '获取推荐失败');
+ }
+
+ const nextResults = Array.isArray(data.results) ? data.results : [];
+ setVideos((prev) => (page === 1 ? nextResults : [...prev, ...nextResults]));
+ setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '获取推荐失败');
+ } finally {
+ setIsLoadingVideos(false);
+ }
+ };
+
+ fetchVideos();
+ }, [selectedSource, page]);
+
+ useEffect(() => {
+ if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ setPage((prev) => prev + 1);
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(loadMoreRef.current);
+ return () => observer.disconnect();
+ }, [hasMore, isLoadingVideos]);
+
+ return (
+
+
+
+
+
+ 高级推荐
+
+
+ 浏览视频源脚本提供的推荐内容
+
+
+
+
+
+
+ {isLoadingSources ? (
+
+
+
+ 加载脚本源中...
+
+
+ ) : sources.length === 0 ? (
+
+ 暂无可用的视频源脚本
+
+ ) : (
+
+ ({
+ label: item.name,
+ value: item.key,
+ }))}
+ active={selectedSource}
+ onChange={setSelectedSource}
+ />
+
+ )}
+
+
+ {!!error && (
+
+ {error}
+
+ )}
+
+ {!isLoadingSources && sources.length > 0 && (
+ <>
+ {videos.length > 0 ? (
+
+ {videos.map((video, index) => (
+
+ ))}
+
+ ) : !isLoadingVideos && !error ? (
+
+ 当前脚本暂无推荐内容
+
+ ) : null}
+
+ {isLoadingVideos && (
+
+
+
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/app/api/acg/acgrip/route.ts b/src/app/api/acg/acgrip/route.ts
index 21be31e..c212884 100644
--- a/src/app/api/acg/acgrip/route.ts
+++ b/src/app/api/acg/acgrip/route.ts
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { parseStringPromise } from 'xml2js';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
export const runtime = 'nodejs';
@@ -48,11 +50,17 @@ export async function POST(req: NextRequest) {
}
// 请求 acg.rip RSS
- const searchUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
+ const config = await getConfig();
+ const searchBaseUrl = getMagnetBaseUrl(
+ 'https://acg.rip',
+ config.SiteConfig.MagnetAcgripReverseProxy
+ );
+ const searchUrl = `${searchBaseUrl}/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
- const response = await fetch(searchUrl, {
+ const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
},
});
@@ -123,4 +131,3 @@ export async function POST(req: NextRequest) {
);
}
}
-
diff --git a/src/app/api/acg/dmhy/route.ts b/src/app/api/acg/dmhy/route.ts
index d8cc158..4fc3b93 100644
--- a/src/app/api/acg/dmhy/route.ts
+++ b/src/app/api/acg/dmhy/route.ts
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { parseStringPromise } from 'xml2js';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
export const runtime = 'nodejs';
@@ -56,11 +58,15 @@ export async function POST(req: NextRequest) {
});
}
- const baseUrl = 'http://share.dmhy.org/topics/rss/rss.xml';
+ const config = await getConfig();
+ const baseUrl = `${getMagnetBaseUrl(
+ 'http://share.dmhy.org',
+ config.SiteConfig.MagnetDmhyReverseProxy
+ )}/topics/rss/rss.xml`;
const params = new URLSearchParams({ keyword: trimmedKeyword });
const searchUrl = `${baseUrl}?${params.toString()}`;
- const response = await fetch(searchUrl, {
+ const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
diff --git a/src/app/api/acg/mikan/route.ts b/src/app/api/acg/mikan/route.ts
index 75c41e6..828f490 100644
--- a/src/app/api/acg/mikan/route.ts
+++ b/src/app/api/acg/mikan/route.ts
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { parseStringPromise } from 'xml2js';
import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
export const runtime = 'nodejs';
@@ -62,11 +64,17 @@ export async function POST(req: NextRequest) {
});
}
- const searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
+ const config = await getConfig();
+ const searchBaseUrl = getMagnetBaseUrl(
+ 'https://mikanani.me',
+ config.SiteConfig.MagnetMikanReverseProxy
+ );
+ const searchUrl = `${searchBaseUrl}/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
- const response = await fetch(searchUrl, {
+ const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
},
});
@@ -144,4 +152,3 @@ export async function POST(req: NextRequest) {
);
}
}
-
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index 2a35297..6619bff 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -50,6 +50,10 @@ export async function POST(request: NextRequest) {
PansouUsername,
PansouPassword,
PansouKeywordBlocklist,
+ MagnetProxy,
+ MagnetMikanReverseProxy,
+ MagnetDmhyReverseProxy,
+ MagnetAcgripReverseProxy,
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
@@ -91,6 +95,10 @@ export async function POST(request: NextRequest) {
PansouUsername?: string;
PansouPassword?: string;
PansouKeywordBlocklist?: string;
+ MagnetProxy?: string;
+ MagnetMikanReverseProxy?: string;
+ MagnetDmhyReverseProxy?: string;
+ MagnetAcgripReverseProxy?: string;
EnableComments: boolean;
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number;
@@ -132,6 +140,10 @@ export async function POST(request: NextRequest) {
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') ||
(PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') ||
+ (MagnetProxy !== undefined && typeof MagnetProxy !== 'string') ||
+ (MagnetMikanReverseProxy !== undefined && typeof MagnetMikanReverseProxy !== 'string') ||
+ (MagnetDmhyReverseProxy !== undefined && typeof MagnetDmhyReverseProxy !== 'string') ||
+ (MagnetAcgripReverseProxy !== undefined && typeof MagnetAcgripReverseProxy !== 'string') ||
typeof EnableComments !== 'boolean' ||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
@@ -188,6 +200,10 @@ export async function POST(request: NextRequest) {
PansouUsername,
PansouPassword,
PansouKeywordBlocklist,
+ MagnetProxy,
+ MagnetMikanReverseProxy,
+ MagnetDmhyReverseProxy,
+ MagnetAcgripReverseProxy,
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
diff --git a/src/app/api/admin/source-script/route.ts b/src/app/api/admin/source-script/route.ts
new file mode 100644
index 0000000..f5c3fc1
--- /dev/null
+++ b/src/app/api/admin/source-script/route.ts
@@ -0,0 +1,164 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { db } from '@/lib/db';
+import {
+ deleteSourceScript,
+ getDefaultSourceScriptTemplate,
+ importSourceScripts,
+ listSourceScripts,
+ saveSourceScript,
+ testSourceScript,
+ toggleSourceScriptEnabled,
+} from '@/lib/source-script';
+
+export const runtime = 'nodejs';
+
+async function assertAdmin(request: NextRequest) {
+ const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
+ if (storageType === 'localstorage') {
+ throw new Error('不支持本地存储进行管理员配置');
+ }
+
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return null;
+ }
+
+ if (authInfo.username === process.env.USERNAME) {
+ return authInfo.username;
+ }
+
+ const userInfoV2 = await db.getUserInfoV2(authInfo.username);
+ if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
+ return null;
+ }
+
+ return authInfo.username;
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const username = await assertAdmin(request);
+ if (!username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const items = await listSourceScripts();
+ return NextResponse.json(
+ {
+ items,
+ template: getDefaultSourceScriptTemplate(),
+ },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message || '获取脚本列表失败' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const username = await assertAdmin(request);
+ if (!username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = (await request.json()) as Record;
+ const action = body.action as string;
+
+ switch (action) {
+ case 'save': {
+ const saved = await saveSourceScript({
+ id: body.id,
+ key: body.key,
+ name: body.name,
+ description: body.description,
+ code: body.code,
+ enabled: body.enabled,
+ });
+ return NextResponse.json(
+ { ok: true, item: saved },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ }
+ case 'delete': {
+ await deleteSourceScript(body.id);
+ return NextResponse.json(
+ { ok: true },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ }
+ case 'toggle_enabled': {
+ const item = await toggleSourceScriptEnabled(body.id);
+ return NextResponse.json(
+ { ok: true, item },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ }
+ case 'test': {
+ const result = await testSourceScript({
+ code: body.code,
+ hook: body.hook,
+ payload: body.payload || {},
+ name: body.name,
+ key: body.key,
+ configValues: body.configValues,
+ });
+
+ if (!result.ok) {
+ return NextResponse.json(result, { status: 400 });
+ }
+
+ return NextResponse.json(result, {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ });
+ }
+ case 'import': {
+ const imported = await importSourceScripts(
+ Array.isArray(body.items) ? body.items : []
+ );
+ return NextResponse.json(
+ { ok: true, items: imported },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ }
+ default:
+ return NextResponse.json({ error: '未知操作' }, { status: 400 });
+ }
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: (error as Error).message || '脚本操作失败',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/advanced-recommendation/sources/route.ts b/src/app/api/advanced-recommendation/sources/route.ts
new file mode 100644
index 0000000..3d0db61
--- /dev/null
+++ b/src/app/api/advanced-recommendation/sources/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { listEnabledSourceScripts } from '@/lib/source-script';
+
+export const runtime = 'nodejs';
+
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const scripts = await listEnabledSourceScripts();
+
+ return NextResponse.json({
+ sources: scripts.map((item) => ({
+ key: item.key,
+ name: item.name,
+ description: item.description,
+ })),
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: '获取高级推荐脚本失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/advanced-recommendation/videos/route.ts b/src/app/api/advanced-recommendation/videos/route.ts
new file mode 100644
index 0000000..004cfc7
--- /dev/null
+++ b/src/app/api/advanced-recommendation/videos/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import {
+ executeSavedSourceScript,
+ normalizeScriptRecommendResults,
+ normalizeScriptSources,
+} from '@/lib/source-script';
+
+export const runtime = 'nodejs';
+
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const sourceKey = searchParams.get('source');
+ const page = Number(searchParams.get('page') || '1');
+
+ if (!sourceKey) {
+ return NextResponse.json({ error: '缺少参数: source' }, { status: 400 });
+ }
+
+ try {
+ let sources = [{ id: 'default', name: '默认源' }];
+
+ try {
+ const sourcesExecution = await executeSavedSourceScript({
+ key: sourceKey,
+ hook: 'getSources',
+ payload: {},
+ });
+ sources = normalizeScriptSources(sourcesExecution.result);
+ } catch {
+ // 允许脚本未实现 getSources,继续使用默认源
+ }
+
+ const execution = await executeSavedSourceScript({
+ key: sourceKey,
+ hook: 'recommend',
+ payload: { page },
+ });
+
+ const results = normalizeScriptRecommendResults({
+ scriptKey: sourceKey,
+ scriptName: execution.meta?.name || sourceKey,
+ result: execution.result,
+ sources,
+ defaultSourceId: sources[0]?.id || 'default',
+ });
+
+ return NextResponse.json({
+ results,
+ page: Number(execution.result?.page || page),
+ pageCount: Number(execution.result?.pageCount || 1),
+ total: Number(execution.result?.total || results.length),
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message || '获取高级推荐失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts
index 224df4d..4db9bc8 100644
--- a/src/app/api/cms-proxy/route.ts
+++ b/src/app/api/cms-proxy/route.ts
@@ -10,6 +10,7 @@ import {
setCachedMetaInfo,
} from '@/lib/openlist-cache';
import { getTMDBImageUrl } from '@/lib/tmdb.search';
+import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const apiUrl = searchParams.get('api');
+ const yellowFilter = searchParams.get('yellowFilter') === 'true';
if (!apiUrl) {
return NextResponse.json(
@@ -96,7 +98,7 @@ export async function GET(request: NextRequest) {
console.log('CMS 代理 origin:', origin);
// 处理返回数据,替换播放链接为代理链接
- const processedData = processPlayUrls(data, origin);
+ const processedData = processCmsResponse(data, origin, yellowFilter);
return NextResponse.json(processedData, {
headers: {
@@ -130,7 +132,7 @@ export async function GET(request: NextRequest) {
/**
* 处理 CMS API 返回数据,将播放链接替换为代理链接
*/
-function processPlayUrls(data: any, proxyOrigin: string): any {
+function processCmsResponse(data: any, proxyOrigin: string, yellowFilter: boolean): any {
if (!data || typeof data !== 'object') {
return data;
}
@@ -138,6 +140,28 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
// 深拷贝数据,避免修改原始对象
const processedData = JSON.parse(JSON.stringify(data));
+ if (yellowFilter) {
+ if (processedData.class && Array.isArray(processedData.class)) {
+ processedData.class = processedData.class.filter((item: any) => !matchesYellowContent(item?.type_name));
+ }
+
+ if (processedData.list && Array.isArray(processedData.list)) {
+ processedData.list = processedData.list.filter((item: any) => !matchesYellowContent(
+ item?.vod_name,
+ item?.type_name,
+ item?.vod_remarks,
+ item?.vod_content,
+ ));
+
+ if (typeof processedData.total === 'number') {
+ processedData.total = processedData.list.length;
+ }
+ if (typeof processedData.limit === 'number') {
+ processedData.limit = processedData.list.length;
+ }
+ }
+ }
+
// 获取 M3U8 代理 token
const proxyToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '';
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
@@ -174,6 +198,19 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
return processedData;
}
+function matchesYellowContent(...values: Array): boolean {
+ const normalized = values
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+
+ if (!normalized) {
+ return false;
+ }
+
+ return yellowWords.some((word) => normalized.includes(word.toLowerCase()));
+}
+
/**
* 处理播放地址字符串
* 格式: 第01集$url1#第02集$url2#...
diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts
index ae3a696..16fd81a 100644
--- a/src/app/api/detail/route.ts
+++ b/src/app/api/detail/route.ts
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
+import {
+ executeSavedSourceScript,
+ normalizeScriptDetailResult,
+ normalizeScriptSources,
+ parseScriptSourceValue,
+} from '@/lib/source-script';
export const runtime = 'nodejs';
@@ -20,6 +26,49 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
+ const parsedScriptSource = parseScriptSourceValue(sourceCode);
+ if (parsedScriptSource) {
+ try {
+ const sourcesExecution = await executeSavedSourceScript({
+ key: parsedScriptSource.scriptKey,
+ hook: 'getSources',
+ payload: {},
+ });
+ const sources = normalizeScriptSources(sourcesExecution.result);
+ const sourceInfo =
+ sources.find((item) => item.id === parsedScriptSource.sourceId) || {
+ id: parsedScriptSource.sourceId,
+ name: parsedScriptSource.sourceId,
+ };
+
+ const detailExecution = await executeSavedSourceScript({
+ key: parsedScriptSource.scriptKey,
+ hook: 'detail',
+ payload: {
+ id,
+ sourceId: parsedScriptSource.sourceId,
+ },
+ });
+
+ const normalized = normalizeScriptDetailResult({
+ source: sourceCode,
+ scriptKey: parsedScriptSource.scriptKey,
+ scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey,
+ sourceId: parsedScriptSource.sourceId,
+ sourceName: sourceInfo.name,
+ detailId: id,
+ result: detailExecution.result,
+ });
+
+ return NextResponse.json(normalized);
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+ }
+
// 特殊处理 openlist 源
if (sourceCode === 'openlist') {
try {
diff --git a/src/app/api/douban-comments/route.ts b/src/app/api/douban-comments/route.ts
index 7648d93..8d37f32 100644
--- a/src/app/api/douban-comments/route.ts
+++ b/src/app/api/douban-comments/route.ts
@@ -1,4 +1,4 @@
-import * as cheerio from 'cheerio';
+import * as cheerio from 'cheerio/slim';
import { NextRequest, NextResponse } from 'next/server';
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
diff --git a/src/app/api/douban-recommendations/route.ts b/src/app/api/douban-recommendations/route.ts
index d4cf231..a2df950 100644
--- a/src/app/api/douban-recommendations/route.ts
+++ b/src/app/api/douban-recommendations/route.ts
@@ -1,4 +1,4 @@
-import * as cheerio from 'cheerio';
+import * as cheerio from 'cheerio/slim';
import { NextRequest, NextResponse } from 'next/server';
import { fetchDoubanData } from '@/lib/douban';
diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts
index 61a4c30..f7deacb 100644
--- a/src/app/api/search/one/route.ts
+++ b/src/app/api/search/one/route.ts
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
+import {
+ executeSavedSourceScript,
+ listEnabledSourceScripts,
+ normalizeScriptSearchResults,
+ normalizeScriptSources,
+} from '@/lib/source-script';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
@@ -37,6 +43,69 @@ export async function GET(request: NextRequest) {
const apiSites = await getAvailableApiSites(authInfo.username);
try {
+ const enabledScripts = await listEnabledSourceScripts();
+ const matchedScript = enabledScripts.find((item) => item.key === resourceId);
+ if (matchedScript) {
+ const sourcesExecution = await executeSavedSourceScript({
+ key: matchedScript.key,
+ hook: 'getSources',
+ payload: {},
+ });
+ const sources = normalizeScriptSources(sourcesExecution.result);
+ const scriptResults = await Promise.all(
+ sources.map(async (source) => {
+ const execution = await executeSavedSourceScript({
+ key: matchedScript.key,
+ hook: 'search',
+ payload: {
+ keyword: query,
+ page: 1,
+ sourceId: source.id,
+ },
+ });
+
+ return normalizeScriptSearchResults({
+ scriptKey: matchedScript.key,
+ scriptName: matchedScript.name,
+ sourceId: source.id,
+ sourceName: source.name,
+ result: execution.result,
+ });
+ })
+ );
+
+ let result = scriptResults.flat().filter((r) => r.title === query);
+ if (!config.SiteConfig.DisableYellowFilter) {
+ result = result.filter((item) => {
+ const typeName = item.type_name || '';
+ return !yellowWords.some((word: string) => typeName.includes(word));
+ });
+ }
+
+ const cacheTime = await getCacheTime();
+ if (result.length === 0) {
+ return NextResponse.json(
+ {
+ error: '未找到结果',
+ result: null,
+ },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(
+ { results: result },
+ {
+ headers: {
+ 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
+ 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
+ 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
+ 'Netlify-Vary': 'query',
+ },
+ }
+ );
+ }
+
// 根据 resourceId 查找对应的 API 站点
const targetSite = apiSites.find((site) => site.key === resourceId);
if (!targetSite) {
diff --git a/src/app/api/search/resources/route.ts b/src/app/api/search/resources/route.ts
index a4eb218..3947ae9 100644
--- a/src/app/api/search/resources/route.ts
+++ b/src/app/api/search/resources/route.ts
@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAvailableApiSites } from '@/lib/config';
+import { listEnabledSourceScripts } from '@/lib/source-script';
export const runtime = 'nodejs';
@@ -11,8 +12,13 @@ export async function GET(request: NextRequest) {
console.log('request', request.url);
try {
const apiSites = await getAvailableApiSites();
+ const scriptSites = (await listEnabledSourceScripts()).map((item) => ({
+ key: item.key,
+ name: item.name,
+ script: true,
+ }));
- return NextResponse.json(apiSites);
+ return NextResponse.json([...apiSites, ...scriptSites]);
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index 0630ee4..1e10e01 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -5,8 +5,14 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
-import { yellowWords } from '@/lib/yellow';
import { getProxyToken } from '@/lib/emby-token';
+import {
+ executeSavedSourceScript,
+ listEnabledSourceScripts,
+ normalizeScriptSearchResults,
+ normalizeScriptSources,
+} from '@/lib/source-script';
+import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
@@ -83,6 +89,7 @@ export async function GET(request: NextRequest) {
id: item.Id,
source: sourceValue,
source_name: sourceName,
+ weight: weightMap.get(sourceValue) ?? 0,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined),
episodes: [],
@@ -138,6 +145,7 @@ export async function GET(request: NextRequest) {
id: folderName,
source: 'openlist',
source_name: '私人影库',
+ weight: weightMap.get('openlist') ?? 0,
title: info.title,
poster: getTMDBImageUrl(info.poster_path),
episodes: [],
@@ -176,24 +184,81 @@ export async function GET(request: NextRequest) {
})
);
+ const scriptSummaries = await listEnabledSourceScripts();
+ const scriptPromises = scriptSummaries.map((script) =>
+ Promise.race([
+ (async () => {
+ try {
+ const sourcesExecution = await executeSavedSourceScript({
+ key: script.key,
+ hook: 'getSources',
+ payload: {},
+ });
+ const sources = normalizeScriptSources(sourcesExecution.result);
+
+ const searchResults = await Promise.all(
+ sources.map(async (source) => {
+ const execution = await executeSavedSourceScript({
+ key: script.key,
+ hook: 'search',
+ payload: {
+ keyword: query,
+ page: 1,
+ sourceId: source.id,
+ },
+ });
+
+ return normalizeScriptSearchResults({
+ scriptKey: script.key,
+ scriptName: script.name,
+ sourceId: source.id,
+ sourceName: source.name,
+ result: execution.result,
+ });
+ })
+ );
+
+ return searchResults.flat();
+ } catch (error) {
+ console.error(`[Search] 搜索脚本 ${script.name} 失败:`, error);
+ return [];
+ }
+ })(),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000)
+ ),
+ ]).catch((error) => {
+ console.error(`[Search] 搜索脚本 ${script.name} 超时:`, error);
+ return [];
+ })
+ );
+
try {
const allResults = await Promise.all([
openlistPromise,
...embyPromises,
...searchPromises,
+ ...scriptPromises,
]);
// 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果
// 添加安全检查,确保即使某个结果处理出错也不影响其他结果
const openlistResults = Array.isArray(allResults[0]) ? allResults[0] : [];
const embyResultsArray = allResults.slice(1, 1 + embyPromises.length);
- const apiResults = allResults.slice(1 + embyPromises.length);
+ const apiResults = allResults.slice(1 + embyPromises.length, 1 + embyPromises.length + searchPromises.length);
+ const scriptResults = allResults.slice(1 + embyPromises.length + searchPromises.length);
// 合并所有 Emby 结果,添加安全检查
const embyResults = embyResultsArray.filter(Array.isArray).flat();
const apiResultsFlat = apiResults.filter(Array.isArray).flat();
+ const scriptResultsFlat = scriptResults.filter(Array.isArray).flat();
- let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat];
+ let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat, ...scriptResultsFlat];
+
+ flattenedResults = flattenedResults.map((result) => ({
+ ...result,
+ weight: result.weight ?? (weightMap.get(result.source) ?? 0),
+ }));
if (!config.SiteConfig.DisableYellowFilter) {
flattenedResults = flattenedResults.filter((result) => {
@@ -204,8 +269,8 @@ export async function GET(request: NextRequest) {
// 按权重降序排序
flattenedResults.sort((a, b) => {
- const weightA = weightMap.get(a.source) ?? 0;
- const weightB = weightMap.get(b.source) ?? 0;
+ const weightA = a.weight ?? 0;
+ const weightB = b.weight ?? 0;
return weightB - weightA;
});
diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts
index 14aed20..f8b750b 100644
--- a/src/app/api/search/ws/route.ts
+++ b/src/app/api/search/ws/route.ts
@@ -7,6 +7,12 @@ import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
import { getProxyToken } from '@/lib/emby-token';
+import {
+ executeSavedSourceScript,
+ listEnabledSourceScripts,
+ normalizeScriptSearchResults,
+ normalizeScriptSources,
+} from '@/lib/source-script';
export const runtime = 'nodejs';
@@ -61,6 +67,7 @@ export async function GET(request: NextRequest) {
config.EmbyConfig.Sources.length > 0 &&
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
);
+ const enabledScripts = await listEnabledSourceScripts();
// 共享状态
let streamClosed = false;
@@ -103,7 +110,7 @@ export async function GET(request: NextRequest) {
const startEvent = `data: ${JSON.stringify({
type: 'start',
query,
- totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
+ totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length,
timestamp: Date.now()
})}\n\n`;
@@ -147,6 +154,7 @@ export async function GET(request: NextRequest) {
id: item.Id,
source: sourceValue,
source_name: sourceName,
+ weight: weightMap.get(sourceValue) ?? 0,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined),
episodes: [],
@@ -253,6 +261,7 @@ export async function GET(request: NextRequest) {
id: key,
source: 'openlist',
source_name: '私人影库',
+ weight: weightMap.get('openlist') ?? 0,
title: info.title,
poster: getTMDBImageUrl(info.poster_path),
episodes: [],
@@ -335,6 +344,11 @@ export async function GET(request: NextRequest) {
});
}
+ filteredResults = filteredResults.map((result) => ({
+ ...result,
+ weight: result.weight ?? (weightMap.get(result.source) ?? 0),
+ }));
+
// 发送该源的搜索结果
completedSources++;
@@ -380,7 +394,7 @@ export async function GET(request: NextRequest) {
}
// 检查是否所有源都已完成
- if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
+ if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) {
if (!streamClosed) {
// 发送最终完成事件
const completeEvent = `data: ${JSON.stringify({
@@ -402,8 +416,118 @@ export async function GET(request: NextRequest) {
}
});
+ const scriptPromises = enabledScripts.map(async (script) => {
+ try {
+ const sourcesExecution = await Promise.race([
+ executeSavedSourceScript({
+ key: script.key,
+ hook: 'getSources',
+ payload: {},
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000)
+ ),
+ ]);
+
+ const sources = normalizeScriptSources((sourcesExecution as any).result);
+ const sourceResults = await Promise.all(
+ sources.map(async (source) => {
+ const execution = await Promise.race([
+ executeSavedSourceScript({
+ key: script.key,
+ hook: 'search',
+ payload: {
+ keyword: query,
+ page: 1,
+ sourceId: source.id,
+ },
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`${script.name}/${source.name} timeout`)), 20000)
+ ),
+ ]);
+
+ return normalizeScriptSearchResults({
+ scriptKey: script.key,
+ scriptName: script.name,
+ sourceId: source.id,
+ sourceName: source.name,
+ result: (execution as any).result,
+ });
+ })
+ );
+
+ let filteredResults = sourceResults.flat();
+ if (!config.SiteConfig.DisableYellowFilter) {
+ filteredResults = filteredResults.filter((result) => {
+ const typeName = result.type_name || '';
+ return !yellowWords.some((word: string) => typeName.includes(word));
+ });
+ }
+
+ completedSources++;
+
+ if (!streamClosed) {
+ const sourceEvent = `data: ${JSON.stringify({
+ type: 'source_result',
+ source: `script:${script.key}`,
+ sourceName: script.name,
+ results: filteredResults,
+ timestamp: Date.now()
+ })}\n\n`;
+
+ if (!safeEnqueue(encoder.encode(sourceEvent))) {
+ streamClosed = true;
+ return;
+ }
+ }
+
+ if (filteredResults.length > 0) {
+ allResults.push(...filteredResults);
+ }
+ } catch (error) {
+ console.warn(`搜索脚本失败 ${script.name}:`, error);
+
+ completedSources++;
+
+ if (!streamClosed) {
+ const errorEvent = `data: ${JSON.stringify({
+ type: 'source_error',
+ source: `script:${script.key}`,
+ sourceName: script.name,
+ error: error instanceof Error ? error.message : '搜索失败',
+ timestamp: Date.now()
+ })}\n\n`;
+
+ if (!safeEnqueue(encoder.encode(errorEvent))) {
+ streamClosed = true;
+ return;
+ }
+ }
+ }
+
+ if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) {
+ if (!streamClosed) {
+ const completeEvent = `data: ${JSON.stringify({
+ type: 'complete',
+ totalResults: allResults.length,
+ completedSources,
+ timestamp: Date.now()
+ })}\n\n`;
+
+ if (safeEnqueue(encoder.encode(completeEvent))) {
+ try {
+ controller.close();
+ } catch (error) {
+ console.warn('Failed to close controller:', error);
+ }
+ }
+ }
+ }
+ });
+
// 等待所有搜索完成
- await Promise.allSettled(searchPromises);
+ await Promise.allSettled([...searchPromises, ...scriptPromises]);
},
cancel() {
diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts
index 1e60a95..def5431 100644
--- a/src/app/api/server-config/route.ts
+++ b/src/app/api/server-config/route.ts
@@ -3,7 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
-import { CURRENT_VERSION } from '@/lib/version'
+import { CURRENT_VERSION } from '@/lib/version';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; // 禁用缓存
@@ -13,14 +13,22 @@ export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
- // 观影室配置从环境变量读取
+ const isLiteMode = process.env.MOONTV_LITE === 'true';
+
+ // Lite 镜像不暴露内置观影室能力,避免前端尝试连接本地 Socket.IO 服务
// 注意:不要暴露 externalServerAuth 到前端,这是敏感凭据
- const watchRoomConfig = {
- enabled: process.env.WATCH_ROOM_ENABLED === 'true',
- serverType: (process.env.WATCH_ROOM_SERVER_TYPE as 'internal' | 'external') || 'internal',
- externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
- // externalServerAuth 不应该暴露给前端
- };
+ const watchRoomConfig = isLiteMode
+ ? {
+ enabled: false,
+ serverType: 'external' as const,
+ externalServerUrl: undefined,
+ }
+ : {
+ enabled: process.env.WATCH_ROOM_ENABLED === 'true',
+ serverType:
+ (process.env.WATCH_ROOM_SERVER_TYPE as 'internal' | 'external') || 'internal',
+ externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
+ };
// 如果使用 localStorage,返回默认配置
if (storageType === 'localstorage') {
diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts
index 1616174..381cf62 100644
--- a/src/app/api/source-detail/route.ts
+++ b/src/app/api/source-detail/route.ts
@@ -6,6 +6,12 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { getDetailFromApiV2 } from '@/lib/downstream';
import { getProxyToken } from '@/lib/emby-token';
+import {
+ executeSavedSourceScript,
+ normalizeScriptDetailResult,
+ normalizeScriptSources,
+ parseScriptSourceValue,
+} from '@/lib/source-script';
export const runtime = 'nodejs';
@@ -28,6 +34,49 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
+ const parsedScriptSource = parseScriptSourceValue(sourceCode);
+ if (parsedScriptSource) {
+ try {
+ const sourcesExecution = await executeSavedSourceScript({
+ key: parsedScriptSource.scriptKey,
+ hook: 'getSources',
+ payload: {},
+ });
+ const sources = normalizeScriptSources(sourcesExecution.result);
+ const sourceInfo =
+ sources.find((item) => item.id === parsedScriptSource.sourceId) || {
+ id: parsedScriptSource.sourceId,
+ name: parsedScriptSource.sourceId,
+ };
+
+ const detailExecution = await executeSavedSourceScript({
+ key: parsedScriptSource.scriptKey,
+ hook: 'detail',
+ payload: {
+ id,
+ sourceId: parsedScriptSource.sourceId,
+ },
+ });
+
+ const normalized = normalizeScriptDetailResult({
+ source: sourceCode,
+ scriptKey: parsedScriptSource.scriptKey,
+ scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey,
+ sourceId: parsedScriptSource.sourceId,
+ sourceName: sourceInfo.name,
+ detailId: id,
+ result: detailExecution.result,
+ });
+
+ return NextResponse.json(normalized);
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+ }
+
// 特殊处理 emby 源(支持多源)
if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) {
try {
diff --git a/src/app/api/source-script/play/route.ts b/src/app/api/source-script/play/route.ts
new file mode 100644
index 0000000..4b6126f
--- /dev/null
+++ b/src/app/api/source-script/play/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import {
+ parseScriptPlayUrlValue,
+ resolveSavedScriptPlayUrl,
+} from '@/lib/source-script';
+
+export const runtime = 'nodejs';
+
+/**
+ * GET /api/source-script/play?key=xxx&sourceId=xxx&episodeIndex=0&playUrl=base64url&format=json
+ * format=json: 返回 JSON 格式(用于 play 页面)
+ * 默认: 返回重定向(用于播放器或外部调用)
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const key = searchParams.get('key');
+ const sourceId = searchParams.get('sourceId');
+ const episodeIndexRaw = searchParams.get('episodeIndex');
+ const playUrlEncoded = searchParams.get('playUrl');
+ const format = searchParams.get('format');
+
+ if (!key || !sourceId || !episodeIndexRaw || !playUrlEncoded) {
+ return NextResponse.json({ error: '缺少参数' }, { status: 400 });
+ }
+
+ const episodeIndex = Number.parseInt(episodeIndexRaw, 10);
+ if (!Number.isInteger(episodeIndex) || episodeIndex < 0) {
+ return NextResponse.json({ error: '无效的 episodeIndex' }, { status: 400 });
+ }
+
+ const playUrl = parseScriptPlayUrlValue(playUrlEncoded);
+ if (!playUrl) {
+ return NextResponse.json({ error: '无效的播放地址' }, { status: 400 });
+ }
+
+ const result = await resolveSavedScriptPlayUrl({
+ key,
+ sourceId,
+ episodeIndex,
+ playUrl,
+ });
+
+ if (!result.url || result.url.trim() === '') {
+ throw new Error('获取到的播放链接为空');
+ }
+
+ if (format === 'json') {
+ return NextResponse.json(result);
+ }
+
+ return NextResponse.redirect(result.url);
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts
index 686ada6..0a3165c 100644
--- a/src/app/api/tvbox/subscribe/route.ts
+++ b/src/app/api/tvbox/subscribe/route.ts
@@ -28,6 +28,7 @@ export async function GET(request: NextRequest) {
const token = searchParams.get('token');
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
const adFilter = searchParams.get('adFilter') === 'true'; // 获取去广告参数
+ const yellowFilter = searchParams.get('yellowFilter') === 'true';
if (!token) {
return NextResponse.json(
@@ -89,7 +90,7 @@ export async function GET(request: NextRequest) {
baseUrl = `${proto}://${host}`;
}
- console.log('TVBOX 订阅 baseUrl:', baseUrl, 'adFilter:', adFilter);
+ console.log('TVBOX 订阅 baseUrl:', baseUrl, 'adFilter:', adFilter, 'yellowFilter:', yellowFilter);
// 检查是否配置了 OpenList
const hasOpenList = !!(
@@ -142,9 +143,9 @@ export async function GET(request: NextRequest) {
key: site.key,
name: site.name,
type: 1,
- // 如果开启去广告,使用 CMS 代理;否则使用原始 API
- api: adFilter
- ? `${baseUrl}/api/cms-proxy?api=${encodeURIComponent(site.api)}`
+ // 开启去广告或黄色过滤时使用 CMS 代理
+ api: (adFilter || yellowFilter)
+ ? `${baseUrl}/api/cms-proxy?api=${encodeURIComponent(site.api)}${adFilter ? '&adFilter=true' : ''}${yellowFilter ? '&yellowFilter=true' : ''}`
: site.api,
searchable: 1,
quickSearch: 1,
diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx
index 2a4e8f0..b7d9ee5 100644
--- a/src/app/douban/page.tsx
+++ b/src/app/douban/page.tsx
@@ -30,6 +30,7 @@ function DoubanPageClient() {
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef(null);
const loadingRef = useRef(null);
+ const contentRef = useRef(null);
const debounceTimeoutRef = useRef(null);
// 用于存储最新参数值的 refs
@@ -598,6 +599,34 @@ function DoubanPageClient() {
};
}, [hasMore, isLoadingMore, loading]);
+ // 首屏如果未被撑满,仅在第一页时额外请求一次下一页
+ useEffect(() => {
+ if (
+ loading ||
+ !selectorsReady ||
+ isLoadingMore ||
+ !hasMore ||
+ doubanData.length === 0 ||
+ currentPage !== 0
+ ) {
+ return;
+ }
+
+ const rafId = window.requestAnimationFrame(() => {
+ const contentEl = contentRef.current;
+ if (!contentEl) return;
+
+ const rect = contentEl.getBoundingClientRect();
+ const preloadThreshold = window.innerHeight + 120;
+
+ if (rect.bottom < preloadThreshold) {
+ setCurrentPage(1);
+ }
+ });
+
+ return () => window.cancelAnimationFrame(rafId);
+ }, [loading, selectorsReady, isLoadingMore, hasMore, doubanData.length, currentPage]);
+
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
@@ -781,7 +810,7 @@ function DoubanPageClient() {
{/* 内容展示区域 */}
-
+
{/* 内容网格 */}
{loading || !selectorsReady
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e0eea95..cf54887 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { Inter } from 'next/font/google';
import './globals.css';
import { getConfig } from '@/lib/config';
+import { listEnabledSourceScripts } from '@/lib/source-script';
import { DanmakuCacheCleanup } from '../components/DanmakuCacheCleanup';
import { DownloadBubble } from '../components/DownloadBubble';
@@ -91,6 +92,7 @@ export default async function RootLayout({
let webLiveEnabled = false;
let customAdFilterVersion = 0;
let tuneHubEnabled = false;
+ let advancedRecommendationEnabled = false;
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
@@ -145,6 +147,9 @@ export default async function RootLayout({
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
// TuneHub音乐功能配置
tuneHubEnabled = config.MusicConfig?.TuneHubEnabled || false;
+ // 高级推荐功能配置:存在已启用视频源脚本时显示
+ advancedRecommendationEnabled =
+ (await listEnabledSourceScripts()).length > 0;
// 检查是否启用了 OpenList 功能
openListEnabled = !!(
config.OpenListConfig?.Enabled &&
@@ -207,6 +212,7 @@ export default async function RootLayout({
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
ENABLE_MOVIE_REQUEST: enableMovieRequest,
WEB_LIVE_ENABLED: webLiveEnabled,
+ ADVANCED_RECOMMENDATION_ENABLED: advancedRecommendationEnabled,
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
TUNEHUB_ENABLED: tuneHubEnabled,
FESTIVE_EFFECT_ENABLED:
diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx
index 82e64f6..2020793 100644
--- a/src/app/live/page.tsx
+++ b/src/app/live/page.tsx
@@ -2,9 +2,9 @@
'use client';
-import { Heart, Radio, Tv } from 'lucide-react';
+import { GitBranch, Heart, Radio, Tv } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
deleteFavorite,
@@ -43,6 +43,21 @@ interface LiveChannel {
url: string;
}
+type MergedChannelItem =
+ | {
+ type: 'single';
+ key: string;
+ channel: LiveChannel;
+ }
+ | {
+ type: 'merged';
+ key: string;
+ name: string;
+ group: string;
+ logo: string;
+ channels: LiveChannel[];
+ };
+
// 直播源接口
interface LiveSource {
key: string;
@@ -120,6 +135,7 @@ function LivePageClient() {
// 搜索关键词
const [searchKeyword, setSearchKeyword] = useState('');
+ const [expandedMergedChannels, setExpandedMergedChannels] = useState([]);
// 节目单信息
const [epgData, setEpgData] = useState<{
@@ -1127,6 +1143,69 @@ function LivePageClient() {
return filtered;
};
+ const mergedChannelItems = useMemo(() => {
+ if (!filteredChannels || filteredChannels.length === 0) return [];
+
+ const mergedMap = new Map();
+ const order: string[] = [];
+
+ filteredChannels.forEach((channel) => {
+ const mergedKey = `${channel.group}::${channel.name.trim().toLowerCase()}`;
+ const existing = mergedMap.get(mergedKey);
+
+ if (existing) {
+ existing.channels.push(channel);
+ if (!existing.logo && channel.logo) {
+ existing.logo = channel.logo;
+ }
+ return;
+ }
+
+ mergedMap.set(mergedKey, {
+ key: mergedKey,
+ name: channel.name,
+ group: channel.group,
+ logo: channel.logo,
+ channels: [channel],
+ });
+ order.push(mergedKey);
+ });
+
+ return order.map((key) => {
+ const item = mergedMap.get(key)!;
+ if (item.channels.length === 1) {
+ return {
+ type: 'single',
+ key,
+ channel: item.channels[0],
+ };
+ }
+
+ return {
+ type: 'merged',
+ key,
+ name: item.name,
+ group: item.group,
+ logo: item.logo,
+ channels: item.channels,
+ };
+ });
+ }, [filteredChannels]);
+
+ const toggleMergedChannel = (key: string) => {
+ setExpandedMergedChannels((prev) => (
+ prev.includes(key)
+ ? prev.filter(item => item !== key)
+ : [...prev, key]
+ ));
+ };
+
// 切换分组
const handleGroupChange = (group: string) => {
// 如果正在切换直播源,则禁用分组切换
@@ -2212,7 +2291,7 @@ function LivePageClient() {
{/* 频道列表 */}
-
@@ -2365,45 +2444,151 @@ function LivePageClient() {
{/* 频道列表 */}
- {filteredChannels?.length > 0 ? (
- filteredChannels.map(channel => {
- const isActive = channel.id === currentChannel?.id;
+ {mergedChannelItems?.length > 0 ? (
+ mergedChannelItems.map(item => {
+ if (item.type === 'single') {
+ const channel = item.channel;
+ const isActive = channel.id === currentChannel?.id;
+ return (
+
handleChannelChange(channel)}
+ disabled={isSwitchingSource}
+ className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
+ ? 'opacity-50 cursor-not-allowed'
+ : isActive
+ ? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+ {channel.logo ? (
+

+ ) : (
+
+ )}
+
+
+
+ {channel.name}
+
+
+ {channel.group}
+
+
+
+
+ );
+ }
+
+ const isExpanded = expandedMergedChannels.includes(item.key);
+ const activeLineIndex = item.channels.findIndex(channel => channel.id === currentChannel?.id);
+ const hasActiveChild = activeLineIndex !== -1;
+
return (
-
handleChannelChange(channel)}
- disabled={isSwitchingSource}
- className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
- ? 'opacity-50 cursor-not-allowed'
- : isActive
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
- : 'hover:bg-gray-100 dark:hover:bg-gray-700'
- }`}
+
-
-
- {channel.logo ? (
-

- ) : (
-
- )}
-
-
-
- {channel.name}
+
{
+ handleChannelChange(item.channels[0]);
+ }}
+ disabled={isSwitchingSource}
+ className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
+ ? 'opacity-50 cursor-not-allowed'
+ : hasActiveChild
+ ? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+ {item.logo ? (
+

+ ) : (
+
+ )}
-
- {channel.group}
+
+
+ {item.name}
+
+
+ {item.group}
+ ·
+ {item.channels.length} 条线路
+ {hasActiveChild && (
+ <>
+ ·
+ {`当前线路${activeLineIndex + 1}`}
+ >
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ toggleMergedChannel(item.key);
+ }}
+ className='text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300'
+ >
+ {isExpanded ? '收起' : '展开'}
+
-
-
+
+
+ {isExpanded && (
+
+ {item.channels.map((channel, index) => {
+ const isActive = channel.id === currentChannel?.id;
+ return (
+
handleChannelChange(channel)}
+ disabled={isSwitchingSource}
+ className={`w-full p-3 rounded-lg text-left text-sm transition-all duration-200 ${
+ isSwitchingSource
+ ? 'opacity-50 cursor-not-allowed'
+ : isActive
+ ? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+
+ {`线路${index + 1}`}
+
+ {isActive && (
+
+ 当前播放
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
);
})
) : (
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 68c2df7..1ac0d4c 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -2,7 +2,7 @@
'use client';
-import { AlertCircle, Cloud, Heart, Sparkles, X } from 'lucide-react';
+import { AlertCircle, Cloud, Heart, Loader2, Router, Sparkles, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
@@ -49,8 +49,8 @@ import { getDoubanDetail } from '@/lib/douban.client';
import { getTMDBImageUrl } from '@/lib/tmdb.search';
import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
-import { useEnableComments } from '@/hooks/useEnableComments';
import { useEnableAIComments } from '@/hooks/useEnableAIComments';
+import { useEnableComments } from '@/hooks/useEnableComments';
import { usePlaySync } from '@/hooks/usePlaySync';
import AIChatPanel from '@/components/AIChatPanel';
@@ -86,6 +86,7 @@ interface WakeLockSentinel {
}
function PlayPageClient() {
+ const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080';
const router = useRouter();
const searchParams = useSearchParams();
const enableComments = useEnableComments();
@@ -491,6 +492,7 @@ function PlayPageClient() {
const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false);
const [currentSearchKeyword, setCurrentSearchKeyword] = useState
(''); // 当前搜索使用的关键词
const [toast, setToast] = useState(null);
+ const [isTranscoding, setIsTranscoding] = useState(false);
useEffect(() => {
danmakuSettingsRef.current = danmakuSettings;
@@ -586,6 +588,29 @@ function PlayPageClient() {
return { source };
};
+ const isLazyDetailSource = (source?: string) => {
+ if (!source) return false;
+ return (
+ source === 'openlist' ||
+ source === 'emby' ||
+ source.startsWith('emby_') ||
+ source.startsWith('script:')
+ );
+ };
+
+ const isM3u8LikeUrl = (url?: string) => {
+ if (!url) return false;
+ const normalizedUrl = url.toLowerCase();
+ return normalizedUrl.includes('.m3u8') || normalizedUrl.includes('/m3u8/');
+ };
+
+ const buildAbsoluteUrl = (url: string) => {
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+ return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
+ };
+
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
@@ -1289,6 +1314,110 @@ function PlayPageClient() {
// 视频源代理模式状态
const [sourceProxyMode, setSourceProxyMode] = useState(false);
+ const resolveCurrentExternalPlaybackUrl = async () => {
+ let urlToUse = videoUrl;
+ if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
+ urlToUse = detail.episodes[currentEpisodeIndex];
+ }
+
+ if (!urlToUse) {
+ return null;
+ }
+
+ return buildAbsoluteUrl(urlToUse);
+ };
+
+ const handleCreateTranscodeSession = async () => {
+ if (isTranscoding) return;
+
+ try {
+ setIsTranscoding(true);
+ const currentPlayTime = artPlayerRef.current?.currentTime || 0;
+
+ const sourceUrl = await resolveCurrentExternalPlaybackUrl();
+ if (!sourceUrl) {
+ throw new Error('当前没有可转码的播放链接');
+ }
+
+ const requestHeaders: Record = {};
+ if (sourceUrl.startsWith(window.location.origin)) {
+ if (document.cookie) {
+ requestHeaders.Cookie = document.cookie;
+ }
+ requestHeaders.Referer = `${window.location.origin}/`;
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(`${LOCAL_TRANSCODER_BASE_URL}/v1/sessions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ url: sourceUrl,
+ headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : undefined,
+ subtitle: {
+ mode: 'burn_embedded',
+ stream: 'auto',
+ },
+ refresh: false,
+ }),
+ });
+ } catch {
+ throw new Error('转码服务连接失败');
+ }
+
+ const data = await response.json().catch(() => null);
+ if (!response.ok) {
+ throw new Error(data?.error || data?.message || `转码请求失败 (${response.status})`);
+ }
+
+ const playUrl = data?.playlist_url || data?.play_url;
+ if (!playUrl) {
+ throw new Error('转码器未返回播放地址');
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+
+ currentXiaoyaUrlRef.current = '';
+ proxyAttemptedRef.current = false;
+ resumeTimeRef.current = currentPlayTime > 0 ? currentPlayTime : null;
+ setVideoQualities([]);
+ setVideoError(null);
+ setCorsFailedUrl(null);
+ setIsVideoLoading(true);
+ setVideoLoadingStage('sourceChanging');
+ setVideoUrl(playUrl);
+ setToast({
+ message: '转码任务已创建,等待 3 秒后已切换到转码地址',
+ type: 'success',
+ onClose: () => setToast(null),
+ });
+ } catch (error) {
+ console.error('创建转码任务失败:', error);
+ setToast({
+ message: error instanceof Error ? error.message : '创建转码任务失败',
+ type: 'error',
+ onClose: () => setToast(null),
+ });
+ } finally {
+ setIsTranscoding(false);
+ }
+ };
+
+ const showExternalTranscodeButton = Boolean(
+ detail &&
+ videoUrl &&
+ !videoUrl.startsWith('blob:') &&
+ !isM3u8LikeUrl(videoUrl) &&
+ (
+ detail.source === 'openlist' ||
+ detail.source === 'xiaoya' ||
+ detail.source.startsWith('emby')
+ )
+ );
+
// 总集数
const totalEpisodes = detail?.episodes?.length || 0;
const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链';
@@ -1357,13 +1486,15 @@ function PlayPageClient() {
// 换源加载状态
const [isVideoLoading, setIsVideoLoading] = useState(true);
const [videoLoadingStage, setVideoLoadingStage] = useState<
- 'initing' | 'sourceChanging'
+ 'initing' | 'sourceChanging' | 'episodeChanging'
>('initing');
const [videoError, setVideoError] = useState(null);
// 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮
const [corsFailedUrl, setCorsFailedUrl] = useState(null);
// 标记当前视频是否已经尝试过代理(防止 415→直连→失败→代理 的无限循环)
const proxyAttemptedRef = useRef(false);
+ const videoUrlRequestSeqRef = useRef(0);
+ const lastVideoRequestKeyRef = useRef(null);
// 直链代理域名记忆:检查某个域名是否需要代理
const isDirectplayDomainProxied = (url: string): boolean => {
@@ -1504,22 +1635,6 @@ function PlayPageClient() {
): Promise => {
if (sources.length === 1) return sources[0];
- // 获取配置以获取权重信息
- let weightMap = new Map();
- try {
- const configResponse = await fetch('/api/admin/config');
- if (configResponse.ok) {
- const configData = await configResponse.json();
- if (configData.Config?.SourceConfig) {
- configData.Config.SourceConfig.forEach((source: any) => {
- weightMap.set(source.key, source.weight ?? 0);
- });
- }
- }
- } catch (error) {
- console.warn('获取配置失败,权重将使用默认值0:', error);
- }
-
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
@@ -1602,8 +1717,8 @@ function PlayPageClient() {
if (successfulResults.length === 0) {
console.warn('所有播放源测速都失败,按权重排序');
const sortedByWeight = [...sources].sort((a, b) => {
- const weightA = weightMap.get(a.source) ?? 0;
- const weightB = weightMap.get(b.source) ?? 0;
+ const weightA = a.weight ?? 0;
+ const weightB = b.weight ?? 0;
return weightB - weightA;
});
return sortedByWeight[0];
@@ -1642,7 +1757,7 @@ function PlayPageClient() {
maxSpeed,
minPing,
maxPing,
- weightMap.get(result.source.source) ?? 0
+ result.source.weight ?? 0
),
}));
@@ -1671,7 +1786,7 @@ function PlayPageClient() {
maxSpeed: number,
minPing: number,
maxPing: number,
- weight: number = 0
+ weight = 0
): number => {
let score = 0;
@@ -2122,21 +2237,56 @@ function PlayPageClient() {
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
- // openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过
- if ((detailData?.source === 'openlist' || detailData?.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
+ // 这类源统一先走详情懒加载,如果 episodes 为空则跳过
+ if (isLazyDetailSource(detailData?.source) && (!detailData?.episodes || detailData.episodes.length === 0)) {
return;
}
setVideoUrl('');
return;
}
+ const requestKey = `${detailData.source}|${detailData.id}|${episodeIndex}`;
+ const isEpisodeSwitchRequest = lastVideoRequestKeyRef.current !== requestKey;
+ lastVideoRequestKeyRef.current = requestKey;
+ const requestSeq = ++videoUrlRequestSeqRef.current;
+
let newUrl = detailData?.episodes[episodeIndex] || '';
+ const isXiaoyaLazyPlayUrl = newUrl.startsWith('/api/xiaoya/play');
+
+ if (isEpisodeSwitchRequest && isXiaoyaLazyPlayUrl) {
+ setVideoLoadingStage('episodeChanging');
+ setIsVideoLoading(true);
+ setVideoError(null);
+ setCorsFailedUrl(null);
+
+ if (artPlayerRef.current?.video) {
+ try {
+ const video = artPlayerRef.current.video as HTMLVideoElement;
+ video.pause();
+ video.removeAttribute('src');
+ video.load();
+ } catch (error) {
+ console.warn('切集时清空旧视频源失败:', error);
+ }
+ }
+
+ if (videoUrl) {
+ setVideoUrl('');
+ }
+ }
// 如果是小雅或 openlist 接口,先请求获取真实 URL
- if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) {
+ const isSpecialLazyPlayUrl =
+ isXiaoyaLazyPlayUrl ||
+ newUrl.startsWith('/api/openlist/play') ||
+ newUrl.startsWith('/api/source-script/play');
+
+ if (isSpecialLazyPlayUrl) {
try {
// 保存原始URL(用于后续刷新)
- currentXiaoyaUrlRef.current = newUrl;
+ if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) {
+ currentXiaoyaUrlRef.current = newUrl;
+ }
// 添加 format=json 参数
const separator = newUrl.includes('?') ? '&' : '?';
@@ -2144,6 +2294,9 @@ function PlayPageClient() {
const response = await fetch(fetchUrl);
const data = await response.json();
+ if (requestSeq !== videoUrlRequestSeqRef.current) {
+ return;
+ }
if (data.url) {
newUrl = data.url;
// 保存清晰度列表
@@ -2154,6 +2307,9 @@ function PlayPageClient() {
}
}
} catch (error) {
+ if (requestSeq !== videoUrlRequestSeqRef.current) {
+ return;
+ }
console.error('获取播放链接失败:', error);
setVideoQualities([]);
currentXiaoyaUrlRef.current = ''; // 获取失败,清空
@@ -2171,6 +2327,9 @@ function PlayPageClient() {
currentId || undefined,
episodeIndex
);
+ if (requestSeq !== videoUrlRequestSeqRef.current) {
+ return;
+ }
if (fileSystemCheck.hasLocal && fileSystemCheck.dirHandle) {
// 使用本地文件播放
@@ -2178,7 +2337,7 @@ function PlayPageClient() {
// 读取 m3u8 文件
const fileHandle = await fileSystemCheck.dirHandle.getFileHandle('playlist.m3u8', { create: false });
const file = await fileHandle.getFile();
- let content = await file.text();
+ const content = await file.text();
// 解析 m3u8 文件,为每个 ts 文件创建 Blob URL
const lines = content.split('\n');
@@ -2241,6 +2400,9 @@ function PlayPageClient() {
// 如果没有 File System API 本地文件,检查服务器端本地下载
if (!fileSystemCheck.hasLocal) {
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
+ if (requestSeq !== videoUrlRequestSeqRef.current) {
+ return;
+ }
if (hasLocalFile) {
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
@@ -2268,7 +2430,10 @@ function PlayPageClient() {
}
}
- if (newUrl !== videoUrl) {
+ if (isEpisodeSwitchRequest || newUrl !== videoUrl) {
+ if (requestSeq !== videoUrlRequestSeqRef.current) {
+ return;
+ }
setVideoUrl(newUrl);
}
};
@@ -3253,6 +3418,42 @@ function PlayPageClient() {
}
};
+ const getCachedSourcesData = (query: string): SearchResult[] => {
+ if (typeof window === 'undefined' || !query.trim()) {
+ return [];
+ }
+
+ try {
+ const cacheKey = `search_cache_${query.trim()}`;
+ const cached = sessionStorage.getItem(cacheKey);
+ if (!cached) {
+ return [];
+ }
+
+ const cachedData = JSON.parse(cached);
+ const results = cachedData.filter(
+ (result: SearchResult) =>
+ normalizeTitle(result.title).toLowerCase() ===
+ normalizeTitle(videoTitleRef.current).toLowerCase() &&
+ (videoYearRef.current
+ ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
+ !result.year ||
+ result.year.trim() === '' ||
+ result.year === 'unknown' ||
+ !/^\d{4}$/.test(result.year)
+ : true) &&
+ (searchType
+ ? getType(result) === searchType
+ : true)
+ );
+
+ return applyCorrectionsToSources(results);
+ } catch (error) {
+ console.error('[Play] 读取缓存失败:', error);
+ return [];
+ }
+ };
+
const initAll = async () => {
if (currentSource === 'directplay') {
if (!currentId) {
@@ -3339,22 +3540,34 @@ function PlayPageClient() {
let sourcesInfo: SearchResult[] = [];
if (currentSource && currentId) {
- // 先快速获取当前源的详情
- try {
- // currentSource 已经是完整格式(如 'emby_wumei')
- // 如果是小雅源且有fileName参数,传递给API
- const currentSourceDetail = await fetchSourceDetail(
- currentSource,
- currentId,
- searchTitle || videoTitle,
- currentSource === 'xiaoya' ? fileName : undefined
- );
- if (currentSourceDetail.length > 0) {
- detailData = currentSourceDetail[0];
- sourcesInfo = currentSourceDetail;
+ const cachedSources = getCachedSourcesData(searchTitle || videoTitle);
+ const cachedTarget = cachedSources.find(
+ (source) => source.source === currentSource && source.id === currentId
+ );
+
+ if (cachedTarget?.episodes?.length) {
+ detailData = cachedTarget;
+ sourcesInfo = cachedSources;
+ setAvailableSources(cachedSources);
+ setSourceSearchLoading(false);
+ } else {
+ // 先快速获取当前源的详情
+ try {
+ // currentSource 已经是完整格式(如 'emby_wumei')
+ // 如果是小雅源且有fileName参数,传递给API
+ const currentSourceDetail = await fetchSourceDetail(
+ currentSource,
+ currentId,
+ searchTitle || videoTitle,
+ currentSource === 'xiaoya' ? fileName : undefined
+ );
+ if (currentSourceDetail.length > 0) {
+ detailData = currentSourceDetail[0];
+ sourcesInfo = currentSourceDetail;
+ }
+ } catch (err) {
+ console.error('获取当前源详情失败:', err);
}
- } catch (err) {
- console.error('获取当前源详情失败:', err);
}
// 异步获取其他源信息,不阻塞播放
@@ -3396,9 +3609,9 @@ function PlayPageClient() {
if (target) {
detailData = target;
- // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
- if ((detailData.source === 'openlist' || detailData.source === 'emby' || detailData.source.startsWith('emby_')) && (!detailData.episodes || detailData.episodes.length === 0)) {
- console.log('[Play] OpenList/Emby source has no episodes, fetching detail...');
+ // 这类源统一通过详情接口补全播放数据
+ if (isLazyDetailSource(detailData.source) && (!detailData.episodes || detailData.episodes.length === 0)) {
+ console.log('[Play] Fetching lazy detail for selected source...');
// currentSource 已经是完整格式
const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle);
if (detailSources.length > 0) {
@@ -3431,6 +3644,9 @@ function PlayPageClient() {
// 检查是否为 xiaoya 源
if (s.source === 'xiaoya') return false;
+ // 脚本源详情懒加载,不参与测速
+ if (s.source.startsWith('script:')) return false;
+
return true;
});
@@ -3438,13 +3654,14 @@ function PlayPageClient() {
s.source === 'openlist' ||
s.source === 'emby' ||
s.source.startsWith('emby_') ||
- s.source === 'xiaoya'
+ s.source === 'xiaoya' ||
+ s.source.startsWith('script:')
);
if (sourcesToTest.length > 0) {
detailData = await preferBestSource(sourcesToTest);
} else if (excludedSources.length > 0) {
- // 如果只有 openlist/emby/xiaoya 源,直接使用第一个
+ // 如果只有懒加载详情的源,直接使用第一个
detailData = excludedSources[0];
} else {
detailData = sourcesInfo[0];
@@ -3453,9 +3670,9 @@ function PlayPageClient() {
console.log(detailData.source, detailData.id);
- // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
- if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
- console.log('[Play] OpenList/Emby source has no episodes after selection, fetching detail...');
+ // 这类源统一通过详情接口补全播放数据
+ if (isLazyDetailSource(detailData.source) && (!detailData.episodes || detailData.episodes.length === 0)) {
+ console.log('[Play] Fetching lazy detail after source selection...');
const detailSources = await fetchSourceDetail(detailData.source, detailData.id, detailData.title || videoTitleRef.current);
if (detailSources.length > 0) {
detailData = detailSources[0];
@@ -3770,8 +3987,8 @@ function PlayPageClient() {
return;
}
- // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
- if ((newDetail.source === 'openlist' || newDetail.source === 'emby' || newDetail.source.startsWith('emby_')) && (!newDetail.episodes || newDetail.episodes.length === 0)) {
+ // 这类源统一通过详情接口补全播放数据
+ if (isLazyDetailSource(newDetail.source) && (!newDetail.episodes || newDetail.episodes.length === 0)) {
try {
const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`);
if (detailResponse.ok) {
@@ -3781,10 +3998,10 @@ function PlayPageClient() {
}
newDetail = detailData;
} else {
- throw new Error('获取 openlist 详情失败');
+ throw new Error('获取视频详情失败');
}
} catch (err) {
- console.error('获取 openlist 详情失败:', err);
+ console.error('获取视频详情失败:', err);
setIsVideoLoading(false);
setError('获取视频详情失败,请重试');
return;
@@ -3881,6 +4098,9 @@ function PlayPageClient() {
if (artPlayerRef.current && artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
+ setVideoLoadingStage('episodeChanging');
+ setIsVideoLoading(true);
+ setVideoError(null);
setCurrentEpisodeIndex(episodeNumber);
}
};
@@ -3892,6 +4112,9 @@ function PlayPageClient() {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
+ setVideoLoadingStage('episodeChanging');
+ setIsVideoLoading(true);
+ setVideoError(null);
setCurrentEpisodeIndex(idx - 1);
}
};
@@ -3940,6 +4163,9 @@ function PlayPageClient() {
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
if (!isFiltered) {
+ setVideoLoadingStage('episodeChanging');
+ setIsVideoLoading(true);
+ setVideoError(null);
setCurrentEpisodeIndex(nextIdx);
return;
}
@@ -4078,6 +4304,7 @@ function PlayPageClient() {
episodeTitle?: string;
searchKeyword?: string;
danmakuCount?: number;
+ bypassCache?: boolean;
}) => {
if (!danmakuPluginRef.current) {
console.warn('弹幕插件未初始化');
@@ -4107,7 +4334,13 @@ function PlayPageClient() {
console.log(`[弹幕加载] episodeId=${episodeId}, title="${title}", episodeIndex=${episodeIndex}`);
- const comments = await getDanmakuById(episodeId, title, episodeIndex, metadata);
+ const comments = await getDanmakuById(
+ episodeId,
+ title,
+ episodeIndex,
+ { bypassCache: metadata?.bypassCache === true },
+ metadata
+ );
if (comments.length === 0) {
console.warn('未获取到弹幕数据');
@@ -4270,6 +4503,7 @@ function PlayPageClient() {
episode.episodeId,
title,
nextEpisodeIndex,
+ undefined,
{
animeId: savedAnimeId,
animeTitle: episodesResult.bangumi.animeTitle,
@@ -4321,6 +4555,7 @@ function PlayPageClient() {
episode.episodeId,
title,
nextEpisodeIndex,
+ undefined,
{
animeId: selectedAnime.animeId,
animeTitle: selectedAnime.animeTitle,
@@ -4462,6 +4697,7 @@ function PlayPageClient() {
episodeTitle: selection.episodeTitle,
searchKeyword: selection.searchKeyword,
danmakuCount: selection.danmakuCount,
+ bypassCache: isManual,
});
};
@@ -5079,8 +5315,8 @@ function PlayPageClient() {
return;
}
- // openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过检查
- if ((currentSource === 'openlist' || currentSource === 'emby' || detail?.source === 'openlist' || detail?.source === 'emby') && (!detail || !detail.episodes || detail.episodes.length === 0)) {
+ // 这类源会先异步补全详情,如果 episodes 为空则跳过
+ if (isLazyDetailSource(currentSource || detail?.source) && (!detail || !detail.episodes || detail.episodes.length === 0)) {
return;
}
@@ -7084,6 +7320,11 @@ function PlayPageClient() {
// 条件:当前播放时间 < 10秒 且 播放记录时间 > 10秒
const checkPlayRecordJump = async () => {
try {
+ // 仅在进入播放后的首次检查时处理,避免本次会话新生成的记录触发恢复按钮
+ if (!playRecordJumpInitialCheckRef.current) {
+ return;
+ }
+
// 如果用户已经关闭过跳转按钮,不再显示
if (playRecordJumpDismissedRef.current) {
return;
@@ -8182,6 +8423,31 @@ function PlayPageClient() {
+ {showExternalTranscodeButton && (
+ {
+ e.preventDefault();
+ await handleCreateTranscodeSession();
+ }}
+ disabled={isTranscoding}
+ className={`group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md overflow-hidden border flex-shrink-0 ${
+ isTranscoding
+ ? 'bg-amber-400 text-white border-amber-400 cursor-wait'
+ : 'bg-amber-500 hover:bg-amber-600 text-white border-amber-500 cursor-pointer'
+ }`}
+ title='转码播放'
+ >
+ {isTranscoding ? (
+
+ ) : (
+
+ )}
+
+ {isTranscoding ? '转码中' : '转码'}
+
+
+ )}
+
{/* PotPlayer */}
{
diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx
index 3265d59..e4eeed4 100644
--- a/src/components/DanmakuPanel.tsx
+++ b/src/components/DanmakuPanel.tsx
@@ -1,7 +1,7 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getEpisodes, searchAnime } from '@/lib/danmaku/api';
import type {
@@ -35,6 +35,10 @@ export default function DanmakuPanel({
const [searchError, setSearchError] = useState(null);
const initializedRef = useRef(false); // 标记是否已初始化过
const fileInputRef = useRef(null);
+ const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
+ const [episodeDescending, setEpisodeDescending] = useState(false);
+ const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
+ const episodesPerGroup = 50;
// 搜索弹幕
const handleSearch = useCallback(async (keyword: string) => {
@@ -112,6 +116,7 @@ export default function DanmakuPanel({
const handleBackToResults = useCallback(() => {
setSelectedAnime(null);
setEpisodes([]);
+ setEpisodeGroupIndex(0);
}, []);
// 判断当前剧集是否已选中
@@ -162,6 +167,69 @@ export default function DanmakuPanel({
}
}, [videoTitle]);
+ useEffect(() => {
+ if (episodes.length > 0) {
+ setEpisodeGroupIndex(Math.floor(currentEpisodeIndex / episodesPerGroup));
+ } else {
+ setEpisodeGroupIndex(0);
+ }
+ }, [episodes, currentEpisodeIndex]);
+
+ const episodeGroupCount = Math.ceil(episodes.length / episodesPerGroup);
+
+ const episodeGroups = useMemo(() => {
+ return Array.from({ length: episodeGroupCount }, (_, idx) => {
+ const start = idx * episodesPerGroup + 1;
+ const end = Math.min((idx + 1) * episodesPerGroup, episodes.length);
+ return `${start}-${end}`;
+ });
+ }, [episodeGroupCount, episodes.length]);
+
+ const displayEpisodeGroupIndex = useMemo(() => {
+ if (episodeDescending) {
+ return episodeGroupCount - 1 - episodeGroupIndex;
+ }
+ return episodeGroupIndex;
+ }, [episodeDescending, episodeGroupCount, episodeGroupIndex]);
+
+ const currentGroupEpisodes = useMemo(() => {
+ if (episodes.length === 0) return [];
+
+ const start = episodeGroupIndex * episodesPerGroup;
+ const end = Math.min(start + episodesPerGroup, episodes.length);
+ const groupEpisodes = episodes.slice(start, end);
+ const withEpisodeNumber = groupEpisodes.map((episode, index) => ({
+ ...episode,
+ episodeNumber: start + index + 1,
+ }));
+
+ return episodeDescending ? [...withEpisodeNumber].reverse() : withEpisodeNumber;
+ }, [episodes, episodeDescending, episodeGroupIndex]);
+
+ const getEpisodeDisplayLabel = useCallback((episodeTitle: string, episodeNumber: number) => {
+ if (!episodeTitle) {
+ return String(episodeNumber);
+ }
+
+ if (episodeTitle.match(/^OVA\s+\d+/i)) {
+ return episodeTitle;
+ }
+
+ const sxxexxMatch = episodeTitle.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
+ if (sxxexxMatch) {
+ const season = sxxexxMatch[1].padStart(2, '0');
+ const episode = sxxexxMatch[2];
+ return `S${season}E${episode}`;
+ }
+
+ const match = episodeTitle.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
+ if (match) {
+ return match[1];
+ }
+
+ return String(episodeNumber);
+ }, []);
+
return (
{/* 搜索区域 - 固定在顶部 */}
@@ -278,72 +346,151 @@ export default function DanmakuPanel({
{/* 剧集列表 */}
{!isLoadingEpisodes && episodes.length > 0 && (
-
- {episodes.map((episode, index) => {
- const isSelected = isEpisodeSelected(episode.episodeId);
- return (
-
handleEpisodeSelect(episode)}
- className={`w-full flex items-center gap-3 p-3 rounded-lg text-left
- transition-all duration-200 group border
- ${
- isSelected
- ? 'bg-green-500 text-white border-green-600 shadow-md'
- : 'bg-gray-100 hover:bg-gray-200 border-gray-200 ' +
- 'dark:bg-gray-800 dark:hover:bg-gray-700 dark:border-gray-700 ' +
- 'hover:border-green-500/50 hover:shadow-sm'
- }`}
- >
- {/* 序号徽章 */}
-
- {index + 1}
-
-
- {/* 标题和信息 */}
-
-
- {episode.episodeTitle}
-
-
+
+
+ {episodeGroups.map((label, idx) => {
+ const isActive = idx === displayEpisodeGroupIndex;
+ return (
+
+ setEpisodeGroupIndex(
+ episodeDescending ? episodeGroupCount - 1 - idx : idx
+ )
+ }
+ className={`relative w-20 py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center ${
+ isActive
+ ? 'text-green-500 dark:text-green-400'
+ : 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
}`}
>
-
- 🆔 ID: {episode.episodeId}
-
-
-
-
- {/* 选中标记 */}
- {isSelected && (
-
- )}
-
- {/* 未选中时的箭头 */}
- {!isSelected && (
-
- )}
+ {label}
+ {isActive && (
+
+ )}
+
+ );
+ })}
+
setEpisodeDescending((prev) => !prev)}
+ className='flex-shrink-0 rounded-md p-2 text-gray-700 hover:bg-gray-100 hover:text-green-600 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-400'
+ title={episodeDescending ? '切换正序' : '切换倒序'}
+ >
+
- );
- })}
+
+
setEpisodeViewMode('list')}
+ title='列表视图'
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ episodeViewMode === 'list'
+ ? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
+ : 'text-gray-600 dark:text-gray-400'
+ }`}
+ >
+
+
+
setEpisodeViewMode('grid')}
+ title='格子视图'
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ episodeViewMode === 'grid'
+ ? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
+ : 'text-gray-600 dark:text-gray-400'
+ }`}
+ >
+
+
+
+
+
+
+ {episodeViewMode === 'grid' ? (
+
+ {currentGroupEpisodes.map((episode) => {
+ const isSelected = isEpisodeSelected(episode.episodeId);
+ return (
+
handleEpisodeSelect(episode)}
+ className={`rounded-lg px-3 py-2 text-sm font-medium transition-all ${
+ isSelected
+ ? 'bg-green-500 text-white shadow-md'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
+ }`}
+ title={episode.episodeTitle}
+ >
+
+ {getEpisodeDisplayLabel(episode.episodeTitle, episode.episodeNumber)}
+
+
+ );
+ })}
+
+ ) : (
+
+ {currentGroupEpisodes.map((episode) => {
+ const isSelected = isEpisodeSelected(episode.episodeId);
+ return (
+
handleEpisodeSelect(episode)}
+ className={`w-full flex items-center gap-3 p-3 rounded-lg text-left transition-all duration-200 group border ${
+ isSelected
+ ? 'bg-green-500 text-white border-green-600 shadow-md'
+ : 'bg-gray-100 hover:bg-gray-200 border-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:border-gray-700 hover:border-green-500/50 hover:shadow-sm'
+ }`}
+ >
+
+ {episode.episodeNumber}
+
+
+
+
+ {episode.episodeTitle}
+
+
+
+ 🆔 ID: {episode.episodeId}
+
+
+
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ )}
)}
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index f88f7cd..40cffb4 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Calendar, Clock, Film,Globe, Star, Tag, Users, X } from 'lucide-react';
+import { Calendar, Clock, ExternalLink, Film,Globe, Star, Tag, Users, X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -101,12 +101,38 @@ const DetailPanel: React.FC
= ({
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState('');
+
// 数据源状态管理
const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
const [originalDetailData, setOriginalDetailData] = useState(null);
+ const getExternalUrl = () => {
+ if (currentSource === 'douban' && doubanId) {
+ return `https://movie.douban.com/subject/${doubanId}`;
+ }
+
+ if (currentSource === 'bangumi') {
+ const actualBangumiId = bangumiId || doubanId;
+ if (actualBangumiId) {
+ return `https://bgm.tv/subject/${actualBangumiId}`;
+ }
+ }
+
+ if (currentSource === 'tmdb') {
+ const actualTmdbId = detailData?.tmdbId || tmdbId;
+ const actualMediaType = detailData?.mediaType || type;
+ if (actualTmdbId) {
+ return `https://www.themoviedb.org/${actualMediaType}/${actualTmdbId}`;
+ }
+ }
+
+ return null;
+ };
+
+ const externalUrl = getExternalUrl();
+
// 拖动滚动状态
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
@@ -874,12 +900,26 @@ const DetailPanel: React.FC = ({
{/* 头部 */}
详情
-
-
-
+
+ {externalUrl && (
+ window.open(externalUrl, '_blank', 'noopener,noreferrer')}
+ className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
+ title="打开外部页面"
+ aria-label="打开外部页面"
+ >
+
+
+ )}
+
+
+
+
{/* 内容区域 */}
@@ -1364,12 +1404,26 @@ const DetailPanel: React.FC = ({
{/* 头部 */}
详情
-
-
-
+
+ {externalUrl && (
+ window.open(externalUrl, '_blank', 'noopener,noreferrer')}
+ className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
+ title="打开外部页面"
+ aria-label="打开外部页面"
+ >
+
+
+ )}
+
+
+
+
{/* 内容区域 */}
diff --git a/src/components/GlobalErrorIndicator.tsx b/src/components/GlobalErrorIndicator.tsx
index f05bb67..d0e5d14 100644
--- a/src/components/GlobalErrorIndicator.tsx
+++ b/src/components/GlobalErrorIndicator.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
interface ErrorInfo {
id: string;
@@ -12,6 +12,42 @@ export function GlobalErrorIndicator() {
const [currentError, setCurrentError] = useState(null);
const [isVisible, setIsVisible] = useState(false);
const [isReplacing, setIsReplacing] = useState(false);
+ const [isClosing, setIsClosing] = useState(false);
+ const currentErrorRef = useRef(null);
+ const closeTimerRef = useRef(null);
+ const exitTimerRef = useRef(null);
+
+ const clearCloseTimer = useCallback(() => {
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ }, []);
+
+ const clearExitTimer = useCallback(() => {
+ if (exitTimerRef.current !== null) {
+ window.clearTimeout(exitTimerRef.current);
+ exitTimerRef.current = null;
+ }
+ }, []);
+
+ const handleClose = useCallback(() => {
+ clearCloseTimer();
+ clearExitTimer();
+ setIsClosing(true);
+ setIsReplacing(false);
+
+ exitTimerRef.current = window.setTimeout(() => {
+ setIsVisible(false);
+ setCurrentError(null);
+ setIsClosing(false);
+ exitTimerRef.current = null;
+ }, 300);
+ }, [clearCloseTimer, clearExitTimer]);
+
+ useEffect(() => {
+ currentErrorRef.current = currentError;
+ }, [currentError]);
useEffect(() => {
// 监听自定义错误事件
@@ -23,8 +59,13 @@ export function GlobalErrorIndicator() {
timestamp: Date.now(),
};
+ clearCloseTimer();
+ clearExitTimer();
+ setIsClosing(false);
+ setIsVisible(true);
+
// 如果已有错误,开始替换动画
- if (currentError) {
+ if (currentErrorRef.current) {
setCurrentError(newError);
setIsReplacing(true);
@@ -36,23 +77,32 @@ export function GlobalErrorIndicator() {
// 第一次显示错误
setCurrentError(newError);
}
-
- setIsVisible(true);
};
// 监听错误事件
window.addEventListener('globalError', handleError as EventListener);
return () => {
+ clearCloseTimer();
+ clearExitTimer();
window.removeEventListener('globalError', handleError as EventListener);
};
- }, [currentError]);
+ }, [clearCloseTimer, clearExitTimer]);
- const handleClose = () => {
- setIsVisible(false);
- setCurrentError(null);
- setIsReplacing(false);
- };
+ useEffect(() => {
+ if (!currentError || isClosing) {
+ return;
+ }
+
+ clearCloseTimer();
+ closeTimerRef.current = window.setTimeout(() => {
+ handleClose();
+ }, 5000);
+
+ return () => {
+ clearCloseTimer();
+ };
+ }, [currentError, handleClose, isClosing, clearCloseTimer]);
if (!isVisible || !currentError) {
return null;
@@ -63,6 +113,10 @@ export function GlobalErrorIndicator() {
{/* 错误卡片 */}
diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx
index 6370dc2..2f3d3cd 100644
--- a/src/components/MobileActionSheet.tsx
+++ b/src/components/MobileActionSheet.tsx
@@ -21,6 +21,7 @@ interface MobileActionSheetProps {
sources?: string[]; // 播放源信息
isAggregate?: boolean; // 是否为聚合内容
sourceName?: string; // 播放源名称
+ directLinkUrl?: string; // 直链播放完整链接
currentEpisode?: number; // 当前集数
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
@@ -36,6 +37,7 @@ const MobileActionSheet: React.FC = ({
sources,
isAggregate,
sourceName,
+ directLinkUrl,
currentEpisode,
totalEpisodes,
origin = 'vod',
@@ -253,6 +255,11 @@ const MobileActionSheet: React.FC = ({
)}
+ {directLinkUrl && (
+
+ {directLinkUrl}
+
+ )}
选择操作
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index f28b892..1154e95 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -2,7 +2,7 @@
'use client';
-import { Cat, Clover, Film, FolderOpen, Globe, Home, Star, Tv, Users } from 'lucide-react';
+import { Blend, Cat, Clover, Container, Film, Globe, Home, Star, Tv, TvMinimalPlay, Users } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -51,7 +51,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
href: '/douban?type=show',
},
{
- icon: Tv,
+ icon: TvMinimalPlay,
label: '电视直播',
href: '/live',
},
@@ -89,7 +89,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
href: '/douban?type=show',
},
{
- icon: Tv,
+ icon: TvMinimalPlay,
label: '电视直播',
href: '/live',
},
@@ -107,12 +107,20 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
// 如果配置了 OpenList 或 Emby,添加私人影库入口
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
items.push({
- icon: FolderOpen,
+ icon: Container,
label: '私人影库',
href: '/private-library',
});
}
+ if (runtimeConfig?.ADVANCED_RECOMMENDATION_ENABLED) {
+ items.push({
+ icon: Blend,
+ label: '高级推荐',
+ href: '/advanced-recommendation',
+ });
+ }
+
// 如果启用观影室,添加观影室入口
if (watchRoomContext?.isEnabled) {
items.push({
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 4dea967..ae0f270 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -2,7 +2,7 @@
'use client';
-import { Cat, Clover, Film, FolderOpen, Globe, Home, Menu, Search, Star, Tv, Users } from 'lucide-react';
+import { Blend, Cat, Clover, Container, Film, Globe, Home, Menu, Search, Star, Tv, TvMinimalPlay, Users } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import {
@@ -143,7 +143,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
href: '/douban?type=show',
},
{
- icon: Tv,
+ icon: TvMinimalPlay,
label: '电视直播',
href: '/live',
},
@@ -180,7 +180,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
href: '/douban?type=show',
},
{
- icon: Tv,
+ icon: TvMinimalPlay,
label: '电视直播',
href: '/live',
},
@@ -198,12 +198,20 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
// 如果配置了 OpenList 或 Emby,添加私人影库入口
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
items.push({
- icon: FolderOpen,
+ icon: Container,
label: '私人影库',
href: '/private-library',
});
}
+ if (runtimeConfig?.ADVANCED_RECOMMENDATION_ENABLED) {
+ items.push({
+ icon: Blend,
+ label: '高级推荐',
+ href: '/advanced-recommendation',
+ });
+ }
+
// 如果启用观影室,添加观影室入口
if (watchRoomContext?.isEnabled) {
items.push({
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index 00a3a1c..ad182bf 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -23,6 +23,7 @@ import {
MoveDown,
MoveUp,
Package,
+ Router as RouterIcon,
Rss,
Settings,
Shield,
@@ -78,12 +79,12 @@ export const UserMenu: React.FC = () => {
// 订阅相关状态
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState('');
- const [subscribeUrlWithAdFilter, setSubscribeUrlWithAdFilter] = useState('');
const [copySuccess, setCopySuccess] = useState(false);
- const [copySuccessAdFilter, setCopySuccessAdFilter] = useState(false);
const [tvboxToken, setTvboxToken] = useState('');
const [isResettingToken, setIsResettingToken] = useState(false);
const [isLoadingSubscribeUrl, setIsLoadingSubscribeUrl] = useState(false);
+ const [subscribeAdFilterEnabled, setSubscribeAdFilterEnabled] = useState(false);
+ const [subscribeYellowFilterEnabled, setSubscribeYellowFilterEnabled] = useState(false);
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
@@ -156,7 +157,7 @@ export const UserMenu: React.FC = () => {
isOpen: false,
title: '',
message: '',
- onConfirm: () => {},
+ onConfirm: () => undefined,
});
// 折叠面板状态
@@ -323,13 +324,7 @@ export const UserMenu: React.FC = () => {
const token = data.token;
setTvboxToken(token);
- // 前端拼接订阅链接
- const currentOrigin = window.location.origin;
- const standardUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}`;
- const adFilterUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}&adFilter=true`;
-
- setSubscribeUrl(standardUrl);
- setSubscribeUrlWithAdFilter(adFilterUrl);
+ setSubscribeUrl(buildSubscribeUrl(token, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
}
} catch (error) {
console.error('获取订阅URL失败:', error);
@@ -359,13 +354,7 @@ export const UserMenu: React.FC = () => {
const token = data.token;
setTvboxToken(token);
- // 更新订阅链接
- const currentOrigin = window.location.origin;
- const standardUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}`;
- const adFilterUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}&adFilter=true`;
-
- setSubscribeUrl(standardUrl);
- setSubscribeUrlWithAdFilter(adFilterUrl);
+ setSubscribeUrl(buildSubscribeUrl(token, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
if (messageEl) {
messageEl.textContent = '订阅token已重置!';
@@ -398,6 +387,19 @@ export const UserMenu: React.FC = () => {
});
};
+ const buildSubscribeUrl = (token: string, adFilter: boolean, yellowFilter: boolean) => {
+ const currentOrigin = window.location.origin;
+ const url = new URL('/api/tvbox/subscribe', currentOrigin);
+ url.searchParams.set('token', token);
+ if (adFilter) {
+ url.searchParams.set('adFilter', 'true');
+ }
+ if (yellowFilter) {
+ url.searchParams.set('yellowFilter', 'true');
+ }
+ return url.toString();
+ };
+
// 获取认证信息和存储类型
useEffect(() => {
if (typeof window !== 'undefined') {
@@ -819,7 +821,6 @@ export const UserMenu: React.FC = () => {
const handleCloseSubscribe = () => {
setIsSubscribeOpen(false);
setCopySuccess(false);
- setCopySuccessAdFilter(false);
};
const handleCopySubscribeUrl = async () => {
@@ -833,18 +834,11 @@ export const UserMenu: React.FC = () => {
console.error('复制失败:', error);
}
};
-
- const handleCopySubscribeUrlWithAdFilter = async () => {
- try {
- await navigator.clipboard.writeText(subscribeUrlWithAdFilter);
- setCopySuccessAdFilter(true);
- setTimeout(() => {
- setCopySuccessAdFilter(false);
- }, 2000);
- } catch (error) {
- console.error('复制失败:', error);
- }
- };
+
+ useEffect(() => {
+ if (!tvboxToken || !isSubscribeOpen) return;
+ setSubscribeUrl(buildSubscribeUrl(tvboxToken, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
+ }, [tvboxToken, subscribeAdFilterEnabled, subscribeYellowFilterEnabled, isSubscribeOpen]);
const handleSubmitChangePassword = async () => {
setPasswordError('');
@@ -2833,18 +2827,18 @@ export const UserMenu: React.FC = () => {
{isLoadingSubscribeUrl ? (
<>
- {/* 加载骨架 - 订阅链接(标准) */}
+ {/* 加载骨架 - 开关 */}
-
-
- {/* 加载骨架 - 订阅链接(去广告) */}
+ {/* 加载骨架 - 订阅链接 */}
-
+
@@ -2860,10 +2854,51 @@ export const UserMenu: React.FC = () => {
>
) : (
<>
- {/* 订阅链接(标准) */}
+
+
+ 订阅选项
+
+
+
setSubscribeAdFilterEnabled((prev) => !prev)}
+ className='w-full flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3 text-left bg-gray-50 dark:bg-gray-800/70'
+ >
+
+
+ 去广告
+
+
+ 开启后通过代理处理播放链接,兼容性可能略低
+
+
+
+
+
+
setSubscribeYellowFilterEnabled((prev) => !prev)}
+ className='w-full flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3 text-left bg-gray-50 dark:bg-gray-800/70'
+ >
+
+
+ 黄色过滤
+
+
+ 开启后同样走代理,并在代理搜索时过滤黄色内容
+
+
+
+
+
+
-
- {/* 订阅链接(去广告) */}
-
-
- 订阅链接(去广告)
-
-
-
-
-
- {copySuccessAdFilter ? '已复制' : '复制'}
-
-
-
- 💡 去广告需要经过服务器代理,某些源可能因为区域或兼容问题无法播放
-
+ {(subscribeAdFilterEnabled || subscribeYellowFilterEnabled) && (
+
+ 💡 代理模式已开启,某些源可能因为区域或兼容问题无法播放
+
+ )}
{/* 重置Token按钮 */}
@@ -3566,6 +3581,38 @@ export const UserMenu: React.FC = () => {
+
+ {/* 私人影库转码器 */}
+
+
+
+
+
+ 私人影库转码器
+
+
+ 为私人影库中的 MKV 视频提供转码播放能力,可解析内封字幕并解决部分视频无音频问题,但通常需要较高的本机性能配置。
+
+
+
+ 下载
+
+
+
+
+
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx
index 2dca7f4..95ddcf7 100644
--- a/src/components/VideoCard.tsx
+++ b/src/components/VideoCard.tsx
@@ -21,7 +21,7 @@ import {
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
-import { processImageUrl } from '@/lib/utils';
+import { processImageUrl, base58Decode } from '@/lib/utils';
import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel';
@@ -172,6 +172,14 @@ const VideoCard = forwardRef(function VideoCard
const actualQuery = query || '';
const actualSearchType = type;
const isDirectPlaySource = actualSource === 'directplay';
+ const directLinkUrl = useMemo(() => {
+ if (!isDirectPlaySource || !actualId) return '';
+ try {
+ return base58Decode(actualId);
+ } catch {
+ return '';
+ }
+ }, [isDirectPlaySource, actualId]);
const displayYear = useMemo(() => {
if (!actualYear) return '';
const normalized = actualYear.trim();
@@ -938,6 +946,38 @@ const VideoCard = forwardRef(function VideoCard
)}
+ {/* 竖向模式:顶部直链地址显示 */}
+ {orientation === 'vertical' && isDirectPlaySource && directLinkUrl && (
+
{
+ e.preventDefault();
+ return false;
+ }}
+ >
+
{
+ e.preventDefault();
+ return false;
+ }}
+ title={directLinkUrl}
+ >
+ {directLinkUrl}
+
+
+ )}
+
{actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && (
(function VideoCard
第{currentEpisode}集 · 共{actualEpisodes}集
)}
+
+ {/* 直链地址 */}
+ {isDirectPlaySource && directLinkUrl && (
+
{
+ e.preventDefault();
+ return false;
+ }}
+ title={directLinkUrl}
+ >
+ {directLinkUrl}
+
+ )}
{/* 底部渐变遮罩 - 用于进度条背景 */}
@@ -1487,6 +1546,7 @@ const VideoCard = forwardRef
(function VideoCard
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
isAggregate={isAggregate}
sourceName={cmsData ? undefined : source_name}
+ directLinkUrl={directLinkUrl || undefined}
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 8737083..eb74fc8 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -30,6 +30,11 @@ export interface AdminConfig {
PansouUsername?: string;
PansouPassword?: string;
PansouKeywordBlocklist?: string;
+ // 磁链配置
+ MagnetProxy?: string;
+ MagnetMikanReverseProxy?: string;
+ MagnetDmhyReverseProxy?: string;
+ MagnetAcgripReverseProxy?: string;
// 评论功能开关
EnableComments: boolean;
// 自定义去广告代码
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 42d450f..b883804 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -10,6 +10,31 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
+ {
+ version: "216.0.0",
+ date: "2026-03-30",
+ added: [
+ "新增视频源脚本",
+ "私人影库增加本机转码功能",
+ "tvbox订阅增加黄色过滤",
+ "播放记录显示直链播放的链接",
+ "磁链增加代理配置",
+ "弹幕选集面板增强",
+ "电视直播聚合同名节目",
+ "douban页面大屏自动预加载第二页",
+ "增加docker lite镜像",
+ "详情面板增加外部跳转"
+ ],
+ changed: [
+ "GlobalError自动消失",
+ "站点配置子服务配置项折叠"
+ ],
+ fixed: [
+ "修复手动选择弹幕因缓存问题无法变更弹幕集数",
+ "修复当前集拉回开头显示恢复进度按钮",
+ "修复视频源权重的一些问题"
+ ]
+ },
{
version: "215.0.0",
date: "2026-03-20",
diff --git a/src/lib/config.ts b/src/lib/config.ts
index be14316..6216130 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -256,6 +256,11 @@ async function getInitConfig(configFile: string, subConfig: {
PansouUsername: '',
PansouPassword: '',
PansouKeywordBlocklist: '',
+ // 磁链配置
+ MagnetProxy: '',
+ MagnetMikanReverseProxy: '',
+ MagnetDmhyReverseProxy: '',
+ MagnetAcgripReverseProxy: '',
// 评论功能开关
EnableComments: false,
},
@@ -432,6 +437,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
PansouUsername: '',
PansouPassword: '',
PansouKeywordBlocklist: '',
+ MagnetProxy: '',
+ MagnetMikanReverseProxy: '',
+ MagnetDmhyReverseProxy: '',
+ MagnetAcgripReverseProxy: '',
EnableComments: false,
};
}
@@ -449,6 +458,18 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
}
+ if (adminConfig.SiteConfig.MagnetProxy === undefined) {
+ adminConfig.SiteConfig.MagnetProxy = '';
+ }
+ if (adminConfig.SiteConfig.MagnetMikanReverseProxy === undefined) {
+ adminConfig.SiteConfig.MagnetMikanReverseProxy = '';
+ }
+ if (adminConfig.SiteConfig.MagnetDmhyReverseProxy === undefined) {
+ adminConfig.SiteConfig.MagnetDmhyReverseProxy = '';
+ }
+ if (adminConfig.SiteConfig.MagnetAcgripReverseProxy === undefined) {
+ adminConfig.SiteConfig.MagnetAcgripReverseProxy = '';
+ }
if (!adminConfig.UserConfig) {
adminConfig.UserConfig = { Users: [] };
}
diff --git a/src/lib/danmaku/api.ts b/src/lib/danmaku/api.ts
index 51cc001..1a497d0 100644
--- a/src/lib/danmaku/api.ts
+++ b/src/lib/danmaku/api.ts
@@ -142,6 +142,9 @@ export async function getDanmakuById(
episodeId: number,
title?: string,
episodeIndex?: number,
+ options?: {
+ bypassCache?: boolean;
+ },
metadata?: {
animeId?: number;
animeTitle?: string;
@@ -152,13 +155,15 @@ export async function getDanmakuById(
): Promise {
try {
// 1. 如果提供了 title 和 episodeIndex,先尝试从缓存读取
- if (title && episodeIndex !== undefined) {
+ if (title && episodeIndex !== undefined && !options?.bypassCache) {
const cachedData = await getDanmakuFromCache(title, episodeIndex);
if (cachedData) {
console.log(`[弹幕缓存] 使用缓存: title=${title}, episodeIndex=${episodeIndex}, 数量=${cachedData.comments.length}`);
return cachedData.comments;
}
console.log(`[弹幕缓存] 缓存未命中,从 API 获取: title=${title}, episodeIndex=${episodeIndex}`);
+ } else if (title && episodeIndex !== undefined && options?.bypassCache) {
+ console.log(`[弹幕缓存] 手动选择,跳过缓存读取: title=${title}, episodeIndex=${episodeIndex}, episodeId=${episodeId}`);
} else {
console.log(`[弹幕缓存] 未提供 title/episodeIndex,跳过缓存: episodeId=${episodeId}`);
}
diff --git a/src/lib/douban-anti-crawler.ts b/src/lib/douban-anti-crawler.ts
index 0d158af..985d45b 100644
--- a/src/lib/douban-anti-crawler.ts
+++ b/src/lib/douban-anti-crawler.ts
@@ -1,4 +1,4 @@
-import * as cheerio from 'cheerio';
+import * as cheerio from 'cheerio/slim';
import { createHash } from 'crypto';
/**
diff --git a/src/lib/magnet.client.ts b/src/lib/magnet.client.ts
new file mode 100644
index 0000000..50e8a9e
--- /dev/null
+++ b/src/lib/magnet.client.ts
@@ -0,0 +1,42 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { HttpsProxyAgent } from 'https-proxy-agent';
+import nodeFetch from 'node-fetch';
+
+function isCloudflareEnvironment(): boolean {
+ return process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
+}
+
+export function getMagnetBaseUrl(defaultBaseUrl: string, reverseProxyBaseUrl?: string): string {
+ return (reverseProxyBaseUrl || defaultBaseUrl).replace(/\/+$/, '');
+}
+
+export async function universalMagnetFetch(
+ url: string,
+ proxy?: string,
+ init?: RequestInit
+): Promise {
+ if (isCloudflareEnvironment()) {
+ const response = await fetch(url, {
+ ...init,
+ signal: AbortSignal.timeout(15000),
+ });
+ return response as unknown as Response;
+ }
+
+ const fetchOptions: any = proxy
+ ? {
+ ...init,
+ agent: new HttpsProxyAgent(proxy, {
+ timeout: 30000,
+ keepAlive: false,
+ }),
+ signal: AbortSignal.timeout(30000),
+ }
+ : {
+ ...init,
+ signal: AbortSignal.timeout(15000),
+ };
+
+ return nodeFetch(url, fetchOptions) as unknown as Response;
+}
diff --git a/src/lib/source-script.ts b/src/lib/source-script.ts
new file mode 100644
index 0000000..ab72966
--- /dev/null
+++ b/src/lib/source-script.ts
@@ -0,0 +1,1041 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import * as cheerio from 'cheerio/slim';
+import { nanoid } from 'nanoid';
+
+import { db } from '@/lib/db';
+
+const SOURCE_SCRIPT_REGISTRY_KEY = 'source-script:registry';
+const DEFAULT_TIMEOUT_MS = 20000;
+
+// 绕过 webpack 静态分析,获取真正的 Node.js require
+// eslint-disable-next-line no-eval
+const _nodeRequire = eval('require') as NodeRequire;
+
+// ---- 内存缓存 ----
+let _registryCache: { data: SourceScriptRegistry; ts: number } | null = null;
+const REGISTRY_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
+
+const _compiledCache = new Map();
+const MAX_COMPILED_CACHE_SIZE = 50;
+
+export interface SourceScriptRecord {
+ id: string;
+ key: string;
+ name: string;
+ description?: string;
+ enabled: boolean;
+ version: string;
+ code: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface SourceScriptImportItem {
+ key: string;
+ name: string;
+ description?: string;
+ code: string;
+ enabled?: boolean;
+}
+
+export interface SourceScriptRegistry {
+ items: SourceScriptRecord[];
+}
+
+export interface SourceScriptTestResult {
+ ok: boolean;
+ durationMs: number;
+ logs: string[];
+ meta?: Record;
+ result?: any;
+ error?: string;
+}
+
+export type SourceScriptHook =
+ | 'getSources'
+ | 'search'
+ | 'recommend'
+ | 'detail'
+ | 'resolvePlayUrl';
+
+export interface PublicSourceScriptSummary {
+ id: string;
+ key: string;
+ name: string;
+ description?: string;
+ version: string;
+ updatedAt: number;
+}
+
+export interface ScriptSourceDescriptor {
+ id: string;
+ name: string;
+}
+
+const SCRIPT_SOURCE_PREFIX = 'script:';
+
+const DEFAULT_SCRIPT_TEMPLATE = `return {
+ meta: {
+ name: '示例脚本',
+ author: 'admin'
+ },
+
+ async getSources(ctx) {
+ return [
+ { id: 'main', name: '主站' },
+ { id: 'backup', name: '备用站' }
+ ];
+ },
+
+ async search(ctx, { keyword, page, sourceId }) {
+ ctx.log.info('search', keyword, page, sourceId);
+ return {
+ sourceId,
+ list: [],
+ page,
+ pageCount: 1,
+ total: 0
+ };
+ },
+
+ async recommend(ctx, { page }) {
+ ctx.log.info('recommend', page);
+ return {
+ list: [],
+ page: page || 1,
+ pageCount: 1,
+ total: 0
+ };
+ },
+
+ async detail(ctx, { id, sourceId }) {
+ ctx.log.info('detail', id, sourceId);
+ return {
+ id,
+ sourceId,
+ title: '',
+ poster: '',
+ year: '',
+ desc: '',
+ playbacks: [
+ {
+ sourceId: sourceId || 'main',
+ sourceName: '主站',
+ episodes: [],
+ episodes_titles: []
+ }
+ ]
+ };
+ },
+
+ async resolvePlayUrl(ctx, { playUrl, sourceId, episodeIndex }) {
+ ctx.log.info('resolvePlayUrl', sourceId, episodeIndex, playUrl);
+ return {
+ url: playUrl,
+ type: 'auto',
+ headers: {}
+ };
+ }
+};`;
+
+function getNowVersion() {
+ return new Date().toISOString();
+}
+
+function buildEmptyRegistry(): SourceScriptRegistry {
+ return { items: [] };
+}
+
+async function loadRegistry(): Promise {
+ if (_registryCache && Date.now() - _registryCache.ts < REGISTRY_CACHE_TTL_MS) {
+ return _registryCache.data;
+ }
+
+ const raw = await db.getGlobalValue(SOURCE_SCRIPT_REGISTRY_KEY);
+ if (!raw) {
+ const empty = buildEmptyRegistry();
+ _registryCache = { data: empty, ts: Date.now() };
+ return empty;
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as SourceScriptRegistry;
+ if (!parsed || !Array.isArray(parsed.items)) {
+ const empty = buildEmptyRegistry();
+ _registryCache = { data: empty, ts: Date.now() };
+ return empty;
+ }
+ _registryCache = { data: parsed, ts: Date.now() };
+ return parsed;
+ } catch {
+ const empty = buildEmptyRegistry();
+ _registryCache = { data: empty, ts: Date.now() };
+ return empty;
+ }
+}
+
+async function saveRegistry(registry: SourceScriptRegistry) {
+ _registryCache = null;
+ _compiledCache.clear();
+ await db.setGlobalValue(
+ SOURCE_SCRIPT_REGISTRY_KEY,
+ JSON.stringify(registry)
+ );
+}
+
+function assertScriptKey(key: string) {
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
+ throw new Error('脚本 Key 仅支持字母、数字、下划线和中划线');
+ }
+}
+
+function createLogCollector() {
+ const logs: string[] = [];
+
+ const push = (level: string, args: any[]) => {
+ const rendered = args
+ .map((arg) => {
+ if (typeof arg === 'string') return arg;
+ try {
+ return JSON.stringify(arg);
+ } catch {
+ return String(arg);
+ }
+ })
+ .join(' ');
+
+ logs.push(`[${level}] ${rendered}`);
+ if (logs.length > 50) {
+ logs.shift();
+ }
+ };
+
+ return {
+ logs,
+ log: {
+ info: (...args: any[]) => push('info', args),
+ warn: (...args: any[]) => push('warn', args),
+ error: (...args: any[]) => push('error', args),
+ },
+ };
+}
+
+function withTimeout(promise: Promise, timeoutMs = DEFAULT_TIMEOUT_MS) {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs);
+ }),
+ ]);
+}
+
+function createCacheHelpers(scriptId: string) {
+ const prefix = `source-script-cache:${scriptId}:`;
+
+ return {
+ async get(key: string) {
+ const raw = await db.getGlobalValue(`${prefix}${key}`);
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as { value: string; expiresAt: number };
+ if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
+ await db.deleteGlobalValue(`${prefix}${key}`);
+ return null;
+ }
+ return parsed.value ?? null;
+ } catch {
+ return raw;
+ }
+ },
+ async set(key: string, value: string, ttlSec = 300) {
+ await db.setGlobalValue(
+ `${prefix}${key}`,
+ JSON.stringify({
+ value,
+ expiresAt: Date.now() + ttlSec * 1000,
+ })
+ );
+ },
+ async del(key: string) {
+ await db.deleteGlobalValue(`${prefix}${key}`);
+ },
+ };
+}
+
+function createUtils() {
+ return {
+ buildUrl(base: string, query?: Record) {
+ const url = new URL(base);
+ Object.entries(query || {}).forEach(([key, value]) => {
+ url.searchParams.set(key, String(value));
+ });
+ return url.toString();
+ },
+ joinUrl(base: string, path: string) {
+ return new URL(path, base).toString();
+ },
+ randomUA() {
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36';
+ },
+ sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ },
+ base64Encode(value: string) {
+ return Buffer.from(value, 'utf8').toString('base64');
+ },
+ base64Decode(value: string) {
+ return Buffer.from(value, 'base64').toString('utf8');
+ },
+ now() {
+ return Date.now();
+ },
+ };
+}
+
+function createScriptFactory(code: string) {
+ return new Function(
+ 'require',
+ `"use strict";\n${code}`
+ ) as (req: NodeRequire) => any;
+}
+
+async function createScriptContext(script: SourceScriptRecord, configValues?: Record) {
+ const { logs, log } = createLogCollector();
+ const cache = createCacheHelpers(script.id);
+
+ const fetcher = async (input: {
+ url: string;
+ method?: string;
+ headers?: Record;
+ query?: Record;
+ body?: string;
+ json?: unknown;
+ timeoutMs?: number;
+ }) => {
+ const url = new URL(input.url);
+ Object.entries(input.query || {}).forEach(([key, value]) => {
+ url.searchParams.set(key, String(value));
+ });
+
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error(`不支持的协议: ${url.protocol}`);
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ input.timeoutMs || DEFAULT_TIMEOUT_MS
+ );
+
+ try {
+ const response = await fetch(url.toString(), {
+ method: input.method || 'GET',
+ headers: {
+ ...(input.json ? { 'Content-Type': 'application/json' } : {}),
+ ...(input.headers || {}),
+ },
+ body: input.json !== undefined ? JSON.stringify(input.json) : input.body,
+ signal: controller.signal,
+ });
+
+ return {
+ status: response.status,
+ ok: response.ok,
+ url: response.url,
+ headers: Object.fromEntries(response.headers.entries()),
+ text: () => response.text(),
+ json: () => response.json() as Promise,
+ arrayBuffer: () => response.arrayBuffer(),
+ };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ };
+
+ return {
+ ctx: Object.freeze({
+ fetch: fetcher,
+ request: {
+ get: (url: string, options?: Omit[0], 'url' | 'method'>) =>
+ fetcher({ url, method: 'GET', ...(options || {}) }),
+ post: (url: string, options?: Omit[0], 'url' | 'method'>) =>
+ fetcher({ url, method: 'POST', ...(options || {}) }),
+ async getHtml(url: string, options?: Omit[0], 'url' | 'method'>) {
+ const response = await fetcher({ url, method: 'GET', ...(options || {}) });
+ const text = await response.text();
+ return cheerio.load(text);
+ },
+ async getJson(url: string, options?: Omit[0], 'url' | 'method'>) {
+ const response = await fetcher({ url, method: 'GET', ...(options || {}) });
+ return response.json();
+ },
+ },
+ html: {
+ load: (html: string) => cheerio.load(html),
+ },
+ json: {
+ parse(text: string, fallback?: T) {
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ return fallback as T;
+ }
+ },
+ stringify(value: unknown) {
+ return JSON.stringify(value);
+ },
+ },
+ utils: createUtils(),
+ cache,
+ log,
+ config: {
+ get: (key: string) => configValues?.[key],
+ require: (key: string) => {
+ const value = configValues?.[key];
+ if (!value) {
+ throw new Error(`缺少脚本配置: ${key}`);
+ }
+ return value;
+ },
+ all: () => ({ ...(configValues || {}) }),
+ },
+ runtime: {
+ scriptId: script.id,
+ sourceKey: script.key,
+ sourceName: script.name,
+ version: script.version,
+ },
+ }),
+ logs,
+ };
+}
+
+function normalizeScript(script: any) {
+ if (!script || typeof script !== 'object') {
+ throw new Error('脚本必须返回对象');
+ }
+ return script;
+}
+
+async function getEnabledSourceScriptByKey(key: string) {
+ const registry = await loadRegistry();
+ const item = registry.items.find((record) => record.key === key);
+ if (!item) {
+ throw new Error('脚本不存在');
+ }
+ if (!item.enabled) {
+ throw new Error('脚本已停用');
+ }
+ return item;
+}
+
+function getOrCompileScript(script: SourceScriptRecord) {
+ const cacheKey = `${script.id}:${script.version}`;
+ const cached = _compiledCache.get(cacheKey);
+ if (cached) return cached;
+
+ const factory = createScriptFactory(script.code);
+ const compiled = normalizeScript(factory(_nodeRequire));
+
+ if (_compiledCache.size >= MAX_COMPILED_CACHE_SIZE) {
+ const firstKey = _compiledCache.keys().next().value;
+ if (firstKey) _compiledCache.delete(firstKey);
+ }
+ _compiledCache.set(cacheKey, compiled);
+ return compiled;
+}
+
+async function compileSourceScript(
+ script: SourceScriptRecord,
+ configValues?: Record
+) {
+ const compiled = getOrCompileScript(script);
+ const context = await createScriptContext(script, configValues);
+ return {
+ compiled,
+ ...context,
+ };
+}
+
+export async function executeSavedSourceScript(input: {
+ key: string;
+ hook: SourceScriptHook;
+ payload?: Record;
+ configValues?: Record;
+}): Promise {
+ const startedAt = Date.now();
+ const script = await getEnabledSourceScriptByKey(input.key);
+ const { compiled, ctx, logs } = await compileSourceScript(
+ script,
+ input.configValues
+ );
+
+ const hook = compiled[input.hook];
+ if (typeof hook !== 'function') {
+ throw new Error(`脚本未实现 ${input.hook} hook`);
+ }
+
+ const result = await withTimeout(
+ Promise.resolve(hook(ctx, input.payload || {})),
+ DEFAULT_TIMEOUT_MS
+ );
+
+ return {
+ ok: true,
+ durationMs: Date.now() - startedAt,
+ logs,
+ meta: compiled.meta,
+ result,
+ };
+}
+
+export async function listEnabledSourceScripts(): Promise {
+ const registry = await loadRegistry();
+ return registry.items
+ .filter((item) => item.enabled)
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ .map((item) => ({
+ id: item.id,
+ key: item.key,
+ name: item.name,
+ description: item.description,
+ version: item.version,
+ updatedAt: item.updatedAt,
+ }));
+}
+
+export async function listSourceScripts() {
+ const registry = await loadRegistry();
+ return registry.items.sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export async function getSourceScript(id: string) {
+ const registry = await loadRegistry();
+ return registry.items.find((item) => item.id === id) || null;
+}
+
+export async function saveSourceScript(input: {
+ id?: string;
+ key: string;
+ name: string;
+ description?: string;
+ code: string;
+ enabled?: boolean;
+}) {
+ assertScriptKey(input.key);
+
+ const registry = await loadRegistry();
+ const now = Date.now();
+ const existing = input.id
+ ? registry.items.find((item) => item.id === input.id)
+ : undefined;
+
+ if (!existing && registry.items.some((item) => item.key === input.key)) {
+ throw new Error('脚本 Key 已存在');
+ }
+
+ if (existing) {
+ existing.key = input.key;
+ existing.name = input.name;
+ existing.description = input.description || '';
+ existing.code = input.code;
+ existing.enabled = input.enabled ?? existing.enabled;
+ existing.updatedAt = now;
+ existing.version = getNowVersion();
+ await saveRegistry(registry);
+ return existing;
+ }
+
+ const created: SourceScriptRecord = {
+ id: nanoid(),
+ key: input.key,
+ name: input.name,
+ description: input.description || '',
+ code: input.code,
+ enabled: input.enabled ?? true,
+ version: getNowVersion(),
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ registry.items.unshift(created);
+ await saveRegistry(registry);
+ return created;
+}
+
+export async function importSourceScripts(items: SourceScriptImportItem[]) {
+ const registry = await loadRegistry();
+ const now = Date.now();
+ const imported: SourceScriptRecord[] = [];
+
+ for (const item of items) {
+ if (!item?.key || !item?.name || !item?.code) {
+ throw new Error('导入脚本缺少必要字段: key/name/code');
+ }
+
+ assertScriptKey(item.key);
+
+ const existing = registry.items.find((record) => record.key === item.key);
+
+ if (existing) {
+ existing.name = item.name;
+ existing.description = item.description || '';
+ existing.code = item.code;
+ existing.enabled = item.enabled ?? existing.enabled;
+ existing.updatedAt = now;
+ existing.version = getNowVersion();
+ imported.push(existing);
+ continue;
+ }
+
+ const created: SourceScriptRecord = {
+ id: nanoid(),
+ key: item.key,
+ name: item.name,
+ description: item.description || '',
+ code: item.code,
+ enabled: item.enabled ?? true,
+ version: getNowVersion(),
+ createdAt: now,
+ updatedAt: now,
+ };
+ registry.items.unshift(created);
+ imported.push(created);
+ }
+
+ await saveRegistry(registry);
+ return imported;
+}
+
+export async function deleteSourceScript(id: string) {
+ const registry = await loadRegistry();
+ const nextItems = registry.items.filter((item) => item.id !== id);
+ if (nextItems.length === registry.items.length) {
+ throw new Error('脚本不存在');
+ }
+ registry.items = nextItems;
+ await saveRegistry(registry);
+}
+
+export async function toggleSourceScriptEnabled(id: string) {
+ const registry = await loadRegistry();
+ const target = registry.items.find((item) => item.id === id);
+ if (!target) {
+ throw new Error('脚本不存在');
+ }
+ target.enabled = !target.enabled;
+ target.updatedAt = Date.now();
+ await saveRegistry(registry);
+ return target;
+}
+
+export async function testSourceScript(input: {
+ code: string;
+ hook: SourceScriptHook;
+ payload: Record;
+ name?: string;
+ key?: string;
+ configValues?: Record;
+}): Promise {
+ const startedAt = Date.now();
+ let collectedLogs: string[] = [];
+ try {
+ const tempScript: SourceScriptRecord = {
+ id: 'test-script',
+ key: input.key || 'test-script',
+ name: input.name || '测试脚本',
+ description: '',
+ enabled: true,
+ version: 'test',
+ code: input.code,
+ createdAt: startedAt,
+ updatedAt: startedAt,
+ };
+
+ const factory = createScriptFactory(input.code);
+ const compiled = normalizeScript(factory(_nodeRequire));
+ const hook = compiled[input.hook];
+ if (typeof hook !== 'function') {
+ throw new Error(`脚本未实现 ${input.hook} hook`);
+ }
+
+ const { ctx, logs } = await createScriptContext(tempScript, input.configValues);
+ collectedLogs = logs;
+ const result = await withTimeout(
+ Promise.resolve(hook(ctx, input.payload)),
+ DEFAULT_TIMEOUT_MS
+ );
+
+ return {
+ ok: true,
+ durationMs: Date.now() - startedAt,
+ logs,
+ meta: compiled.meta,
+ result,
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ durationMs: Date.now() - startedAt,
+ logs: collectedLogs,
+ error: (error as Error).message,
+ };
+ }
+}
+
+export function getDefaultSourceScriptTemplate() {
+ return DEFAULT_SCRIPT_TEMPLATE;
+}
+
+export function buildScriptSourceValue(scriptKey: string, sourceId?: string) {
+ return `${SCRIPT_SOURCE_PREFIX}${scriptKey}:${sourceId || 'default'}`;
+}
+
+export function parseScriptSourceValue(source: string) {
+ if (!source.startsWith(SCRIPT_SOURCE_PREFIX)) {
+ return null;
+ }
+
+ const rest = source.slice(SCRIPT_SOURCE_PREFIX.length);
+ const separatorIndex = rest.indexOf(':');
+ if (separatorIndex === -1) {
+ return {
+ scriptKey: rest,
+ sourceId: 'default',
+ };
+ }
+
+ return {
+ scriptKey: rest.slice(0, separatorIndex),
+ sourceId: rest.slice(separatorIndex + 1) || 'default',
+ };
+}
+
+export function normalizeScriptSources(result: any): ScriptSourceDescriptor[] {
+ if (!Array.isArray(result)) {
+ return [{ id: 'default', name: '默认源' }];
+ }
+
+ return result
+ .filter((item) => item && item.id)
+ .map((item) => ({
+ id: String(item.id),
+ name: String(item.name || item.id),
+ }));
+}
+
+export function normalizeScriptSearchResults(input: {
+ scriptKey: string;
+ scriptName: string;
+ sourceId: string;
+ sourceName: string;
+ result: any;
+}) {
+ const list = Array.isArray(input.result?.list) ? input.result.list : [];
+ return list.map((item: any) => {
+ const titles = Array.isArray(item.episodes_titles) ? item.episodes_titles : [];
+ const episodes = Array.isArray(item.episodes)
+ ? item.episodes.map((episode: any, index: number) => {
+ const playUrl =
+ typeof episode === 'string'
+ ? episode
+ : String(episode?.playUrl || episode?.url || '');
+ const needResolve =
+ typeof episode === 'object' && episode
+ ? episode.needResolve !== false
+ : true;
+
+ return needResolve
+ ? buildScriptPlayUrl({
+ scriptKey: input.scriptKey,
+ sourceId: input.sourceId,
+ episodeIndex: index,
+ playUrl,
+ })
+ : playUrl;
+ })
+ : [];
+
+ return {
+ id: String(item.id),
+ title: String(item.title || ''),
+ poster: item.poster || '',
+ episodes,
+ episodes_titles: titles,
+ source: buildScriptSourceValue(input.scriptKey, input.sourceId),
+ source_name: `${input.scriptName} / ${input.sourceName}`,
+ year: item.year || '',
+ desc: item.desc || '',
+ type_name: item.type_name || '',
+ douban_id: item.douban_id || 0,
+ vod_remarks: item.vod_remarks,
+ };
+ });
+}
+
+export function normalizeScriptRecommendResults(input: {
+ scriptKey: string;
+ scriptName: string;
+ result: any;
+ sources?: ScriptSourceDescriptor[];
+ defaultSourceId?: string;
+}) {
+ const list = Array.isArray(input.result?.list) ? input.result.list : [];
+ const sourceMap = new Map(
+ (input.sources || []).map((item) => [String(item.id), String(item.name)])
+ );
+ const fallbackSourceId = input.defaultSourceId || 'default';
+
+ return list.map((item: any) => {
+ const sourceId = String(
+ item?.sourceId || item?.source_id || item?.source || fallbackSourceId
+ );
+ const sourceName = String(
+ item?.sourceName ||
+ item?.source_name ||
+ sourceMap.get(sourceId) ||
+ sourceId
+ );
+
+ return {
+ id: String(item?.id || ''),
+ title: String(item?.title || ''),
+ poster: item?.poster || '',
+ episodes: Array.isArray(item?.episodes) ? item.episodes : [],
+ episodes_titles: Array.isArray(item?.episodes_titles)
+ ? item.episodes_titles
+ : [],
+ source: buildScriptSourceValue(input.scriptKey, sourceId),
+ source_name: `${input.scriptName} / ${sourceName}`,
+ year: item?.year || '',
+ desc: item?.desc || '',
+ type_name: item?.type_name || '',
+ douban_id: item?.douban_id || 0,
+ vod_remarks: item?.vod_remarks,
+ vod_total: item?.vod_total,
+ tmdb_id: item?.tmdb_id,
+ rating: item?.rating,
+ };
+ });
+}
+
+export function normalizeScriptDetailResult(input: {
+ source: string;
+ scriptKey: string;
+ scriptName: string;
+ sourceId: string;
+ sourceName: string;
+ detailId: string;
+ result: any;
+}) {
+ const playbacks = Array.isArray(input.result?.playbacks)
+ ? input.result.playbacks
+ : [
+ {
+ sourceId: input.sourceId,
+ sourceName: input.sourceName,
+ episodes: input.result?.episodes || [],
+ episodes_titles: input.result?.episodes_titles || [],
+ },
+ ];
+
+ const flattenedEpisodes: string[] = [];
+ const flattenedTitles: string[] = [];
+
+ playbacks.forEach((playback: any) => {
+ const playbackSourceName = String(playback.sourceName || input.sourceName);
+ const titles = Array.isArray(playback.episodes_titles)
+ ? playback.episodes_titles
+ : [];
+ const episodes = Array.isArray(playback.episodes) ? playback.episodes : [];
+
+ episodes.forEach((episode: any, index: number) => {
+ const rawPlayUrl =
+ typeof episode === 'string'
+ ? episode
+ : String(episode?.playUrl || episode?.url || '');
+ const episodeTitle =
+ typeof episode === 'object' && episode?.title
+ ? String(episode.title)
+ : String(titles[index] || `第${index + 1}集`);
+
+ const playbackSourceId = String(playback.sourceId || input.sourceId);
+ const needResolve =
+ typeof episode === 'object' && episode
+ ? episode.needResolve !== false
+ : true;
+ const playUrl = needResolve
+ ? buildScriptPlayUrl({
+ scriptKey: input.scriptKey,
+ sourceId: playbackSourceId,
+ episodeIndex: index,
+ playUrl: rawPlayUrl,
+ })
+ : rawPlayUrl;
+
+ flattenedEpisodes.push(playUrl);
+ flattenedTitles.push(`${playbackSourceName} / ${episodeTitle}`);
+ });
+ });
+
+ return {
+ id: input.detailId,
+ title: String(input.result?.title || ''),
+ poster: input.result?.poster || '',
+ episodes: flattenedEpisodes,
+ episodes_titles: flattenedTitles,
+ source: input.source,
+ source_name: `${input.scriptName} / ${input.sourceName}`,
+ class: input.result?.class,
+ year: input.result?.year || '',
+ desc: input.result?.desc || '',
+ type_name: input.result?.type_name || '',
+ douban_id: input.result?.douban_id || 0,
+ vod_remarks: input.result?.vod_remarks,
+ vod_total: input.result?.vod_total,
+ proxyMode: false,
+ };
+}
+
+export async function resolveScriptDetailPlaybacks(input: {
+ scriptKey: string;
+ sourceId: string;
+ result: any;
+}) {
+ const playbacks = Array.isArray(input.result?.playbacks)
+ ? input.result.playbacks
+ : [
+ {
+ sourceId: input.sourceId,
+ sourceName: input.sourceId,
+ episodes: input.result?.episodes || [],
+ episodes_titles: input.result?.episodes_titles || [],
+ },
+ ];
+
+ // 预检查:编译一次脚本,判断是否实现了 resolvePlayUrl
+ const script = await getEnabledSourceScriptByKey(input.scriptKey);
+ const compiled = getOrCompileScript(script);
+
+ if (typeof compiled.resolvePlayUrl !== 'function') {
+ return input.result;
+ }
+
+ // 已实现 resolvePlayUrl,创建一个 context 复用
+ const { ctx } = await createScriptContext(script);
+
+ const resolvedPlaybacks = await Promise.all(
+ playbacks.map(async (playback: any) => {
+ const playbackSourceId = String(playback.sourceId || input.sourceId);
+ const episodes = Array.isArray(playback.episodes) ? playback.episodes : [];
+
+ const resolvedEpisodes = await Promise.all(
+ episodes.map(async (episode: any, index: number) => {
+ const playUrl =
+ typeof episode === 'string'
+ ? episode
+ : String(episode?.playUrl || episode?.url || '');
+
+ try {
+ const result = await withTimeout(
+ Promise.resolve(
+ compiled.resolvePlayUrl(ctx, {
+ playUrl,
+ sourceId: playbackSourceId,
+ episodeIndex: index,
+ })
+ ),
+ DEFAULT_TIMEOUT_MS
+ );
+ return result?.url || playUrl;
+ } catch {
+ return playUrl;
+ }
+ })
+ );
+
+ return {
+ ...playback,
+ episodes: resolvedEpisodes,
+ };
+ })
+ );
+
+ return {
+ ...input.result,
+ playbacks: resolvedPlaybacks,
+ };
+}
+
+function encodeBase64Url(value: string) {
+ return Buffer.from(value, 'utf8')
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+}
+
+function decodeBase64Url(value: string) {
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf8');
+}
+
+export function buildScriptPlayUrl(input: {
+ scriptKey: string;
+ sourceId: string;
+ episodeIndex: number;
+ playUrl: string;
+}) {
+ const searchParams = new URLSearchParams({
+ key: input.scriptKey,
+ sourceId: input.sourceId,
+ episodeIndex: String(input.episodeIndex),
+ playUrl: encodeBase64Url(input.playUrl),
+ });
+ return `/api/source-script/play?${searchParams.toString()}`;
+}
+
+export function parseScriptPlayUrlValue(value: string) {
+ return decodeBase64Url(value);
+}
+
+export async function resolveSavedScriptPlayUrl(input: {
+ key: string;
+ sourceId: string;
+ episodeIndex: number;
+ playUrl: string;
+ configValues?: Record;
+}) {
+ const script = await getEnabledSourceScriptByKey(input.key);
+ const { compiled, ctx } = await compileSourceScript(script, input.configValues);
+
+ if (typeof compiled.resolvePlayUrl !== 'function') {
+ return {
+ url: input.playUrl,
+ type: 'auto',
+ headers: {},
+ };
+ }
+
+ const result = await withTimeout(
+ Promise.resolve(
+ compiled.resolvePlayUrl(ctx, {
+ playUrl: input.playUrl,
+ sourceId: input.sourceId,
+ episodeIndex: input.episodeIndex,
+ })
+ ),
+ DEFAULT_TIMEOUT_MS
+ );
+
+ return {
+ url: result?.url || input.playUrl,
+ type: result?.type || 'auto',
+ headers: result?.headers || {},
+ };
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index e08c0a5..562bddb 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -177,6 +177,7 @@ export interface SearchResult {
episodes_titles: string[];
source: string;
source_name: string;
+ weight?: number; // 播放源权重(来自后台配置,用于排序和优选评分)
class?: string;
year: string;
desc?: string;
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 04e1c34..795a8b7 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
-const CURRENT_VERSION = '215.0.0';
+const CURRENT_VERSION = '216.0.0';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };
diff --git a/src/types/cheerio-slim.d.ts b/src/types/cheerio-slim.d.ts
new file mode 100644
index 0000000..a7525b1
--- /dev/null
+++ b/src/types/cheerio-slim.d.ts
@@ -0,0 +1,3 @@
+declare module 'cheerio/slim' {
+ export * from 'cheerio';
+}