提交orangetv完整代码

This commit is contained in:
贯三秋 2025-09-09 23:21:19 +08:00
commit 84b0aa6e8f
168 changed files with 49601 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
.env
.env*.local

84
.eslintrc.js Normal file
View File

@ -0,0 +1,84 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
extends: [
'eslint:recommended',
'next',
'next/core-web-vitals',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'no-unused-vars': 'off',
'no-console': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'react/no-unescaped-entities': 'off',
'react/display-name': 'off',
'react/jsx-curly-brace-presence': [
'warn',
{ props: 'never', children: 'never' },
],
//#region //*=========== Unused Import ===========
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
//#endregion //*======== Unused Import ===========
//#region //*=========== Import Sort ===========
'simple-import-sort/exports': 'warn',
'simple-import-sort/imports': [
'warn',
{
groups: [
// ext library & side effect imports
['^@?\\w', '^\\u0000'],
// {s}css files
['^.+\\.s?css$'],
// Lib and hooks
['^@/lib', '^@/hooks'],
// static data
['^@/data'],
// components
['^@/components', '^@/container'],
// zustand store
['^@/store'],
// Other imports
['^@/'],
// relative paths up until 3 level
[
'^\\./?$',
'^\\.(?!/?$)',
'^\\.\\./?$',
'^\\.\\.(?!/?$)',
'^\\.\\./\\.\\./?$',
'^\\.\\./\\.\\.(?!/?$)',
'^\\.\\./\\.\\./\\.\\./?$',
'^\\.\\./\\.\\./\\.\\.(?!/?$)',
],
['^@/types'],
// other that didnt fit in
['^'],
],
},
],
//#endregion //*======== Import Sort ===========
},
globals: {
React: true,
JSX: true,
},
};

136
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,136 @@
name: Build & Push Docker image
on:
workflow_dispatch:
inputs:
tag:
description: 'Docker 标签'
required: false
default: 'latest'
type: string
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
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: Set lowercase repository owner
id: lowercase
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/moontechlab/lunatv
tags: |
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable={{is_default_branch}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/moontechlab/lunatv:${{ github.event.inputs.tag || 'latest' }}
outputs: type=image,name=ghcr.io/moontechlab/lunatv,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: Set lowercase repository owner
id: lowercase
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create -t ghcr.io/moontechlab/lunatv:${{ github.event.inputs.tag || 'latest' }} \
$(printf 'ghcr.io/moontechlab/lunatv@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

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# next-sitemap
sitemap.xml
sitemap-*.xml
# generated files
src/lib/runtime.ts
public/manifest.json

4
.husky/commit-msg Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"

4
.husky/post-merge Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm install

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

0
.npmrc Normal file
View File

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v20.10.0

41
.prettierignore Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
.next
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# changelog
CHANGELOG.md
pnpm-lock.yaml

7
.prettierrc.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
arrowParens: 'always',
singleQuote: true,
jsxSingleQuote: true,
tabWidth: 2,
semi: true,
};

10
.vscode/css.code-snippets vendored Normal file
View File

@ -0,0 +1,10 @@
{
"Region CSS": {
"prefix": "regc",
"body": [
"/* #region /**=========== ${1} =========== */",
"$0",
"/* #endregion /**======== ${1} =========== */"
]
}
}

9
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments"
]
}

17
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"css.validate": false,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
// Tailwind CSS Autocomplete, add more if used in projects
"tailwindCSS.classAttributes": [
"class",
"className",
"classNames",
"containerClassName"
],
"typescript.preferences.importModuleSpecifier": "non-relative"
}

193
.vscode/typescriptreact.code-snippets vendored Normal file
View File

@ -0,0 +1,193 @@
{
//#region //*=========== React ===========
"import React": {
"prefix": "ir",
"body": ["import * as React from 'react';"]
},
"React.useState": {
"prefix": "us",
"body": [
"const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
]
},
"React.useEffect": {
"prefix": "uf",
"body": ["React.useEffect(() => {", " $0", "}, []);"]
},
"React.useReducer": {
"prefix": "ur",
"body": [
"const [state, dispatch] = React.useReducer(${0:someReducer}, {",
" ",
"})"
]
},
"React.useRef": {
"prefix": "urf",
"body": ["const ${1:someRef} = React.useRef($0)"]
},
"React Functional Component": {
"prefix": "rc",
"body": [
"import * as React from 'react';\n",
"export default function ${1:${TM_FILENAME_BASE}}() {",
" return (",
" <div>",
" $0",
" </div>",
" )",
"}"
]
},
"React Functional Component with Props": {
"prefix": "rcp",
"body": [
"import * as React from 'react';\n",
"import clsxm from '@/lib/clsxm';\n",
"type ${1:${TM_FILENAME_BASE}}Props= {\n",
"} & React.ComponentPropsWithoutRef<'div'>\n",
"export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
" return (",
" <div className={clsxm(['', className])} {...rest}>",
" $0",
" </div>",
" )",
"}"
]
},
//#endregion //*======== React ===========
//#region //*=========== Commons ===========
"Region": {
"prefix": "reg",
"scope": "javascript, typescript, javascriptreact, typescriptreact",
"body": [
"//#region //*=========== ${1} ===========",
"${TM_SELECTED_TEXT}$0",
"//#endregion //*======== ${1} ==========="
]
},
"Region CSS": {
"prefix": "regc",
"scope": "css, scss",
"body": [
"/* #region /**=========== ${1} =========== */",
"${TM_SELECTED_TEXT}$0",
"/* #endregion /**======== ${1} =========== */"
]
},
//#endregion //*======== Commons ===========
//#region //*=========== Next.js ===========
"Next Pages": {
"prefix": "np",
"body": [
"import * as React from 'react';\n",
"import Layout from '@/components/layout/Layout';",
"import Seo from '@/components/Seo';\n",
"export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
" return (",
" <Layout>",
" <Seo templateTitle='${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}' />\n",
" <main>\n",
" <section className=''>",
" <div className='layout py-20 min-h-screen'>",
" $0",
" </div>",
" </section>",
" </main>",
" </Layout>",
" )",
"}"
]
},
"Next API": {
"prefix": "napi",
"body": [
"import { NextApiRequest, NextApiResponse } from 'next';\n",
"export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
" if (req.method === 'GET') {",
" res.status(200).json({ name: 'Bambang' });",
" } else {",
" res.status(405).json({ message: 'Method Not Allowed' });",
" }",
"}"
]
},
"Get Static Props": {
"prefix": "gsp",
"body": [
"export const getStaticProps = async (context: GetStaticPropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Get Static Paths": {
"prefix": "gspa",
"body": [
"export const getStaticPaths: GetStaticPaths = async () => {",
" return {",
" paths: [",
" { params: { $1 }}",
" ],",
" fallback: ",
" };",
"}"
]
},
"Get Server Side Props": {
"prefix": "gssp",
"body": [
"export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Infer Get Static Props": {
"prefix": "igsp",
"body": "InferGetStaticPropsType<typeof getStaticProps>"
},
"Infer Get Server Side Props": {
"prefix": "igssp",
"body": "InferGetServerSidePropsType<typeof getServerSideProps>"
},
"Import useRouter": {
"prefix": "imust",
"body": ["import { useRouter } from 'next/router';"]
},
"Import Next Image": {
"prefix": "imimg",
"body": ["import Image from 'next/image';"]
},
"Import Next Link": {
"prefix": "iml",
"body": ["import Link from 'next/link';"]
},
//#endregion //*======== Next.js ===========
//#region //*=========== Snippet Wrap ===========
"Wrap with Fragment": {
"prefix": "ff",
"body": ["<>", "\t${TM_SELECTED_TEXT}", "</>"]
},
"Wrap with clsx": {
"prefix": "cx",
"body": ["{clsx([${TM_SELECTED_TEXT}$0])}"]
},
"Wrap with clsxm": {
"prefix": "cxm",
"body": ["{clsxm([${TM_SELECTED_TEXT}$0, className])}"]
},
//#endregion //*======== Snippet Wrap ===========
"Logger": {
"prefix": "lg",
"body": [
"logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
]
}
}

340
CHANGELOG Normal file
View File

@ -0,0 +1,340 @@
## [100.0.0] - 2025-08-26
### Added
- 新增对 SITE_BASE 环境变量的支持,解决 m3u8 重写时 base url 错误的问题
### Changed
- 移除授权相关逻辑
- 移除代码混淆
- 移除 melody-cdn-sharon
## [4.3.0] - 2025-08-26
### Added
- 支持将 IPTV 频道添加到收藏中
### Changed
- 禁用 flv 直播,仅支持 m3u8 直播
- 降低代理 ts 分片的内存占用
## [4.2.1] - 2025-08-26
### Fixed
- 修复直播源加载失败或离开页面后依然无限加载的问题
## [4.2.0] - 2025-08-26
### Added
- 支持 flv 直播和直播地址解析到 mp4 的处理
- 增加直播台标的 proxy 以防止 cors
- 支持播放页选集分组的滚动翻页
### Changed
- 管理后台页面的按钮增加加载中的 UI
### Fixed
- /api/proxy/m3u8 仅对 m3u8 内容反序列化,降低内存和 CPU 消耗
## [4.1.1] - 2025-08-25
### Changed
- 增加对 url-tvg 和多 epg url 的支持
### Fixed
- 修复 epg 数据清洗中去重叠逻辑未考虑日期导致的问题
## [4.1.0] - 2025-08-24
### Added
- 解析 m3u 自带的 epg 和自定义 epg增加今日节目单
### Changed
- 直播源数据刷新改为并发刷新
## [4.0.0] - 2025-08-24
### Added
- 增加 iptv 订阅和播放功能
### Changed
- 搜索页面视频卡片移动端/右键菜单添加豆瓣链接
- 搜索建议遵循色情过滤
## [3.2.1] - 2025-08-22
### Changed
- 新增色色过滤分类
- 调整搜索建议框层级
## [3.2.0] - 2025-08-22
### Added
- 视频源管理支持批量启用、禁用、删除
- 用户管理支持批量设置用户组
- 视频卡片右键/长按菜单新增新标签页播放
### Changed
- 视频卡片移动端 hover 时仅保留播放按钮
- 微调管理页面 UI 和视频卡片右键/长按菜单中的收藏样式
### Fixed
- 修复了搜索栏 enter 键自动选中第一个建议项的问题
## [3.1.2] - 2025-08-22
### Fixed
- 修复移动端卡片无法点击的问题
## [3.1.1] - 2025-08-21
### Fixed
- 修复了视频卡片 hover 的非播放按钮点击后进入播放页的问题
## [3.1.0] - 2025-08-21
### Added
- 增加用户组管理和用户组播放源限制
- 增加管理面板视频源有效性检查
- 搜索栏增加一键删除按钮
### Changed
- 放宽授权心跳对于网络问题的判断标准
- 统一管理面板弹窗使用 createPortal
- VideoCard 允许移动端响应 hover 事件
- 移动端布局 header 常驻,搜索按钮移动到 header 右侧
- 调大搜索接口超时时间
### Fixed
- 修复 bangumi 返回的整数评分无小数导致 UI 不对齐的问题
## [3.0.2] - 2025-08-20
### Changed
- 优化机器码生成逻辑
### Fixed
- 修复 redis url 不支持 rediss 协议的问题
## [3.0.1] - 2025-08-20
### Fixed
- 修复授权初始化错误
## [3.0.0] - 2025-08-20
### Added
- 防盗卖加固
- 支持自定义用户可用视频源
### Changed
- 右键视频卡片可弹出操作菜单
### Fixed
- 过滤掉集数为 0 的搜索结果
## [2.7.1] - 2025-08-17
### Fixed
- 修复 iOS 下版本面板可穿透滚动背景的问题
## [2.7.0] - 2025-08-17
### Added
- 视频卡片新增移动端操作面板,优化触控屏操作体验
### Changed
- 优化集数标题的匹配和展示逻辑
### Fixed
- 修复设置面板和修改密码面板背景可被拖动的问题
## [2.6.0] - 2025-08-17
### Added
- 新增搜索流式输出接口,并设置流式搜索为默认搜索接口,优化搜索体验
- 新增源站搜索结果内存缓存,粒度为源站+关键词+页数,缓存 10 分钟
- 新增豆瓣 CDN provided by @JohnsonRan
### Changed
- 搜索结果默认为无排序状态,不再默认按照年份排序
- 常规搜索接口无结果时,不再设置响应的缓存头
- 移除豆瓣数据源中的 cors-anywhere 方式
### Fixed
- 数据导出时导出站长密码,保证迁移到新账户时原站长用户可正常登录
- 聚合卡片优化移动端源信息展示
## [2.4.1] - 2025-08-15
### Fixed
- 对导入和 db 读取的配置文件做自检,防止 USERNAME 修改导致用户状态异常
## [2.4.0] - 2025-08-15
### Added
- 支持 kvrocks 存储(持久化 kv 存储)
### Fixed
- 修复搜索结果排序不稳定的问题
- 导入数据时同时更新内存缓存的管理员配置
## [2.3.0] - 2025-08-15
### Added
- 支持站长导入导出整站数据
### Changed
- 仅允许站长操作配置文件
- 微调搜索结果过滤面板的移动端样式
## [2.2.1] - 2025-08-14
### Fixed
- 修复了筛选 panel 打开时滚动页面 panel 不跟随的问题
## [2.2.0] - 2025-08-14
### Added
- 搜索结果支持按播放源、标题和年份筛选,支持按年份排序
- 搜索界面视频卡片展示年份信息,聚合卡片展示播放源
### Fixed
- 修复 /api/search/resources 返回空的问题
- 修复 upstash 实例无法编辑自定义分类的问题
## [2.1.0] - 2025-08-13
### Added
- 支持通过订阅获取配置文件
### Changed
- 微调部分文案和 UI
- 删除部分无用代码
## [2.0.1] - 2025-08-13
### Changed
- 版本检查和变更日志请求 Github
### Fixed
- 微调管理面板样式
## [2.0.0] - 2025-08-13
### Added
- 支持配置文件在线配置和编辑
- 搜索页搜索框实时联想
- 去除对 localstorage 模式的支持
### Changed
- 播放记录删除按钮改为垃圾桶图标以消除歧义
### Fixed
- 限制设置面板的最大长度,防止超出视口
## [1.1.1] - 2025-08-12
### Changed
- 修正 zwei 提供的 cors proxy 地址
- 移除废弃代码
### Fixed
- [运维] docker workflow release 日期使用东八区日期
## [1.1.0] - 2025-08-12
### Added
- 每日新番放送功能,展示每日新番放送的番剧
### Fixed
- 修复远程 CHANGELOG 无法提取变更内容的问题
## [1.0.5] - 2025-08-12
### Changed
- 实现基于 Git 标签的自动 Release 工作流
## [1.0.4] - 2025-08-11
### Added
- 优化版本管理工作流,实现单点修改
### Changed
- 版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt
## [1.0.3] - 2025-08-11
### Changed
- 升级播放器 Artplayer 至版本 5.2.5
## [1.0.2] - 2025-08-11
### Changed
- 版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新
- [运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号
## [1.0.1] - 2025-08-11
### Fixed
- 修复版本检查功能,只要与最新版本号不一致即认为有更新
## [1.0.0] - 2025-08-10
### Added
- 基于 Semantic Versioning 的版本号机制
- 版本信息面板,展示本地变更日志和远程更新日志

59
Dockerfile Normal file
View File

@ -0,0 +1,59 @@
# ---- 第 1 阶段:安装依赖 ----
FROM node:20-alpine AS deps
# 启用 corepack 并激活 pnpmNode20 默认提供 corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 仅复制依赖清单,提高构建缓存利用率
COPY package.json pnpm-lock.yaml ./
# 安装所有依赖(含 devDependencies后续会裁剪
RUN pnpm install --frozen-lockfile
# ---- 第 2 阶段:构建项目 ----
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
# 复制全部源代码
COPY . .
# 在构建阶段也显式设置 DOCKER_ENV
ENV DOCKER_ENV=true
# 生成生产构建
RUN pnpm run build
# ---- 第 3 阶段:生成运行时镜像 ----
FROM node:20-alpine AS runner
# 创建非 root 用户
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
# 从构建器中复制 standalone 输出
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录
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"]

437
LICENSE Normal file
View File

@ -0,0 +1,437 @@
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

294
README.md Normal file
View File

@ -0,0 +1,294 @@
# OrangeTV
<div align="center">
<img src="public/logo.png" alt="OrangeTV Logo" width="120">
</div>
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind&nbsp;CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
<div align="center">
![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss)
![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript)
![License](https://img.shields.io/badge/License-MIT-green)
![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker)
</div>
---
## ✨ 功能特性
- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
<details>
<summary>点击查看项目截图</summary>
<img src="public/screenshot1.png" alt="项目截图" style="max-width:600px">
<img src="public/screenshot2.png" alt="项目截图" style="max-width:600px">
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
</details>
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目不授权任何“科技周刊/月刊”类项目或站点收录本项目。
## 🗺 目录
- [技术栈](#技术栈)
- [部署](#部署)
- [配置文件](#配置文件)
- [自动更新](#自动更新)
- [环境变量](#环境变量)
- [AndroidTV 使用](#AndroidTV-使用)
- [Roadmap](#roadmap)
- [安全与隐私提醒](#安全与隐私提醒)
- [License](#license)
- [致谢](#致谢)
## 技术栈
| 分类 | 主要依赖 |
| --------- | ----------------------------------------------------------------------------------------------------- |
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
| UI & 样式 | [Tailwind&nbsp;CSS 3](https://tailwindcss.com/) |
| 语言 | TypeScript 4 |
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
| 代码质量 | ESLint · Prettier · Jest |
| 部署 | Docker |
## 部署
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
### Kvrocks 存储(推荐)
```yml
services:
OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0
container_name: OrangeTV-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
- KVROCKS_URL=redis://OrangeTV-kvrocks:6666
networks:
- OrangeTV-network
depends_on:
- OrangeTV-kvrocks
OrangeTV-kvrocks:
image: apache/kvrocks
container_name: OrangeTV-kvrocks
restart: unless-stopped
volumes:
- kvrocks-data:/var/lib/kvrocks
networks:
- OrangeTV-network
networks:
OrangeTV-network:
driver: bridge
volumes:
kvrocks-data:
```
### Redis 存储(有一定的丢数据风险)
```yml
services:
OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0
container_name: OrangeTV-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=redis
- REDIS_URL=redis://OrangeTV-redis:6379
networks:
- OrangeTV-network
depends_on:
- OrangeTV-redis
OrangeTV-redis:
image: redis:alpine
container_name: OrangeTV-redis
restart: unless-stopped
networks:
- OrangeTV-network
# 请开启持久化,否则升级/重启后数据丢失
volumes:
- ./data:/data
networks:
OrangeTV-network:
driver: bridge
```
### Upstash 存储
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
3. 使用如下 docker compose
```yml
services:
OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0
container_name: OrangeTV-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=upstash
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
- UPSTASH_TOKEN=上面的 TOKEN
```
## 配置文件
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件(后续会支持订阅)
配置文件示例如下:
```json
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://xxx.com/api.php/provide/vod",
"name": "示例资源",
"detail": "http://xxx.com"
}
// ...更多站点
},
"custom_category": [
{
"name": "华语",
"type": "movie",
"query": "华语"
}
]
}
```
- `cache_time`:接口缓存时间(秒)。
- `api_site`:你可以增删或替换任何资源站,字段说明:
- `key`:唯一标识,保持小写字母/数字。
- `api`:资源站提供的 `vod` JSON API 根地址。
- `name`:在人机界面中展示的名称。
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL用于爬取。
- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类。以 type + query 作为唯一标识。支持以下字段:
- `name`:分类显示名称(可选,如不提供则使用 query 作为显示名)
- `type`:分类类型,支持 `movie`(电影)或 `tv`(电视剧)
- `query`:搜索关键词,用于在豆瓣 API 中搜索相关内容
custom_category 支持的自定义分类已知如下:
- movie热门、最新、经典、豆瓣高分、冷门佳片、华语、欧美、韩国、日本、动作、喜剧、爱情、科幻、悬疑、恐怖、治愈
- tv热门、美剧、英剧、韩剧、日剧、国产剧、港剧、日本动画、综艺、纪录片
也可输入如 "哈利波特" 效果等同于豆瓣搜索
OrangeTV 支持标准的苹果 CMS V10 API 格式。
## 自动更新
可借助 [watchtower](https://github.com/containrrr/watchtower) 自动更新镜像容器
dockge/komodo 等 docker compose UI 也有自动更新功能
## 环境变量
| 变量 | 说明 | 可选值 | 默认值 |
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
- direct: 由服务器直接请求豆瓣源站
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxy由 NEXT_PUBLIC_DOUBAN_PROXY 定义
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
- direct由浏览器直接请求豆瓣分配的默认图片域名
- server由服务器代理请求豆瓣分配的默认图片域名
- img3由浏览器请求豆瓣官方的精品 cdn阿里云
- cmliussss-cdn-tencent由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxy由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
## AndroidTV 使用
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
已实现播放记录和网页端同步
## 安全与隐私提醒
### 请设置密码保护并关闭公网注册
为了您的安全和避免潜在的法律风险,我们要求在部署时**强烈建议关闭公网注册**
### 部署要求
1. **设置环境变量 `PASSWORD`**:为您的实例设置一个强密码
2. **仅供个人使用**:请勿将您的实例链接公开分享或传播
3. **遵守当地法律**:请确保您的使用行为符合当地法律法规
### 重要声明
- 本项目仅供学习和个人使用
- 请勿将部署的实例用于商业用途或公开服务
- 如因公开分享导致的任何法律问题,用户需自行承担责任
- 项目开发者不对用户的使用行为承担任何法律责任
- 本项目不在中国大陆地区提供服务。如有该项目在向中国大陆地区提供服务,属个人行为。在该地区使用所产生的法律风险及责任,属于用户个人行为,与本项目无关,须自行承担全部责任。特此声明
## License
[MIT](LICENSE) © 2025 OrangeTV & Contributors
## 致谢
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
- [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。
- [艾福森昵] - 感谢论坛佬友提供的短剧API
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。
- [Zwei](https://github.com/bestzwei) — 提供获取豆瓣数据的 cors proxy
- [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务
- 感谢所有提供免费影视接口的站点。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=MoonTechLab/LunaTV&type=Date)](https://www.star-history.com/#MoonTechLab/LunaTV&Date)

1
VERSION.txt Normal file
View File

@ -0,0 +1 @@
8.8.8

24
commitlint.config.js Normal file
View File

@ -0,0 +1,24 @@
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',
],
],
},
};

30
jest.config.js Normal file
View File

@ -0,0 +1,30 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
/**
* Absolute imports and Module Path Aliases
*/
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1',
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

5
jest.setup.js Normal file
View File

@ -0,0 +1,5 @@
import '@testing-library/jest-dom/extend-expect';
// Allow router mocks.
// eslint-disable-next-line no-undef
jest.mock('next/router', () => require('next-router-mock'));

80
next.config.js Normal file
View File

@ -0,0 +1,80 @@
/** @type {import('next').NextConfig} */
/* eslint-disable @typescript-eslint/no-var-requires */
const nextConfig = {
output: 'standalone',
eslint: {
dirs: ['src'],
ignoreDuringBuilds: process.env.DOCKER_ENV === 'true',
},
reactStrictMode: false,
swcMinify: false,
experimental: {
instrumentationHook: process.env.NODE_ENV === 'production',
},
// Uncoment to add domain whitelist
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg')
);
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: { not: /\.(css|scss|sass)$/ },
resourceQuery: { not: /url/ }, // exclude if *.svg?url
loader: '@svgr/webpack',
options: {
dimensions: false,
titleProp: true,
},
}
);
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
config.resolve.fallback = {
...config.resolve.fallback,
net: false,
tls: false,
crypto: false,
};
return config;
},
};
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
});
module.exports = withPWA(nextConfig);

96
package.json Normal file
View File

@ -0,0 +1,96 @@
{
"name": "OrangeTV",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:manifest && next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
"typecheck": "tsc --noEmit --incremental false",
"test:watch": "jest --watch",
"test": "jest",
"format": "prettier -w .",
"format:check": "prettier -c .",
"gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@types/crypto-js": "^4.2.2",
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.2.5",
"artplayer-plugin-danmuku": "^5.2.0",
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.10",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.4.0",
"react-image-crop": "^11.0.10",
"redis": "^4.6.7",
"swiper": "^11.2.8",
"tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15",
"zod": "^3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^16.3.0",
"@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7",
"@types/bs58": "^5.0.0",
"@types/he": "^1.2.3",
"@types/node": "24.0.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.23",
"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",
"prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^4.9.5",
"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"
}

11416
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

240
proxy.worker.js Normal file
View File

@ -0,0 +1,240 @@
/* eslint-disable */
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
try {
const url = new URL(request.url);
// 如果访问根目录返回HTML
if (url.pathname === '/') {
return new Response(getRootHtml(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
// 从请求路径中提取目标 URL
let actualUrlStr = decodeURIComponent(url.pathname.replace('/', ''));
// 判断用户输入的 URL 是否带有协议
actualUrlStr = ensureProtocol(actualUrlStr, url.protocol);
// 保留查询参数
actualUrlStr += url.search;
// 创建新 Headers 对象,排除以 'cf-' 开头的请求头
const newHeaders = filterHeaders(
request.headers,
(name) => !name.startsWith('cf-')
);
// 创建一个新的请求以访问目标 URL
const modifiedRequest = new Request(actualUrlStr, {
headers: newHeaders,
method: request.method,
body: request.body,
redirect: 'manual',
});
// 发起对目标 URL 的请求
const response = await fetch(modifiedRequest);
let body = response.body;
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.status)) {
body = response.body;
// 创建新的 Response 对象以修改 Location 头部
return handleRedirect(response, body);
} else if (response.headers.get('Content-Type')?.includes('text/html')) {
body = await handleHtmlContent(
response,
url.protocol,
url.host,
actualUrlStr
);
}
// 创建修改后的响应对象
const modifiedResponse = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// 添加禁用缓存的头部
setNoCacheHeaders(modifiedResponse.headers);
// 添加 CORS 头部,允许跨域访问
setCorsHeaders(modifiedResponse.headers);
return modifiedResponse;
} catch (error) {
// 如果请求目标地址时出现错误,返回带有错误消息的响应和状态码 500服务器错误
return jsonResponse(
{
error: error.message,
},
500
);
}
}
// 确保 URL 带有协议
function ensureProtocol(url, defaultProtocol) {
return url.startsWith('http://') || url.startsWith('https://')
? url
: defaultProtocol + '//' + url;
}
// 处理重定向
function handleRedirect(response, body) {
const location = new URL(response.headers.get('location'));
const modifiedLocation = `/${encodeURIComponent(location.toString())}`;
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
...response.headers,
Location: modifiedLocation,
},
});
}
// 处理 HTML 内容中的相对路径
async function handleHtmlContent(response, protocol, host, actualUrlStr) {
const originalText = await response.text();
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
let modifiedText = replaceRelativePaths(
originalText,
protocol,
host,
new URL(actualUrlStr).origin
);
return modifiedText;
}
// 替换 HTML 内容中的相对路径
function replaceRelativePaths(text, protocol, host, origin) {
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
return text.replace(regex, `$1${protocol}//${host}/${origin}/`);
}
// 返回 JSON 格式的响应
function jsonResponse(data, status) {
return new Response(JSON.stringify(data), {
status: status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
}
// 过滤请求头
function filterHeaders(headers, filterFunc) {
return new Headers([...headers].filter(([name]) => filterFunc(name)));
}
// 设置禁用缓存的头部
function setNoCacheHeaders(headers) {
headers.set('Cache-Control', 'no-store');
}
// 设置 CORS 头部
function setCorsHeaders(headers) {
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
headers.set('Access-Control-Allow-Headers', '*');
}
// 返回根目录的 HTML
function getRootHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
<title>Proxy Everything</title>
<link rel="icon" type="image/png" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="Description" content="Proxy Everything with CF Workers.">
<meta property="og:description" content="Proxy Everything with CF Workers.">
<meta property="og:image" content="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="robots" content="index, follow">
<meta http-equiv="Content-Language" content="zh-CN">
<meta name="copyright" content="Copyright © ymyuuu">
<meta name="author" content="ymyuuu">
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<style>
body, html {
height: 100%;
margin: 0;
}
.background {
background-image: url('https://imgapi.cn/bing.php');
background-size: cover;
background-position: center;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background-color: rgba(255, 255, 255, 0.8);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
background-color: rgba(255, 255, 255, 1);
box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.3);
}
.input-field input[type=text] {
color: #2c3e50;
}
.input-field input[type=text]:focus+label {
color: #2c3e50 !important;
}
.input-field input[type=text]:focus {
border-bottom: 1px solid #2c3e50 !important;
box-shadow: 0 1px 0 0 #2c3e50 !important;
}
</style>
</head>
<body>
<div class="background">
<div class="container">
<div class="row">
<div class="col s12 m8 offset-m2 l6 offset-l3">
<div class="card">
<div class="card-content">
<span class="card-title center-align"><i class="material-icons left">link</i>Proxy Everything</span>
<form id="urlForm" onsubmit="redirectToProxy(event)">
<div class="input-field">
<input type="text" id="targetUrl" placeholder="在此输入目标地址" required>
<label for="targetUrl">目标地址</label>
</div>
<button type="submit" class="btn waves-effect waves-light teal darken-2 full-width">跳转</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script>
function redirectToProxy(event) {
event.preventDefault();
const targetUrl = document.getElementById('targetUrl').value.trim();
const currentOrigin = window.location.origin;
window.open(currentOrigin + '/' + encodeURIComponent(targetUrl), '_blank');
}
</script>
</body>
</html>`;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# 禁止所有搜索引擎爬取
User-agent: *
Disallow: /

BIN
public/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

BIN
public/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

View File

@ -0,0 +1,229 @@
#!/usr/bin / env node
/* eslint-disable */
const fs = require('fs');
const path = require('path');
function parseChangelog(content) {
const lines = content.split('\n');
const versions = [];
let currentVersion = null;
let currentSection = null;
let inVersionContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = trimmedLine.match(
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
);
if (versionMatch) {
if (currentVersion) {
versions.push(currentVersion);
}
currentVersion = {
version: versionMatch[1],
date: versionMatch[2],
added: [],
changed: [],
fixed: [],
content: [], // 用于存储原始内容,当没有分类时使用
};
currentSection = null;
inVersionContent = true;
continue;
}
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
if (inVersionContent && currentVersion) {
// 匹配章节标题
if (trimmedLine === '### Added') {
currentSection = 'added';
continue;
} else if (trimmedLine === '### Changed') {
currentSection = 'changed';
continue;
} else if (trimmedLine === '### Fixed') {
currentSection = 'fixed';
continue;
}
// 匹配条目: - 内容
if (trimmedLine.startsWith('- ') && currentSection) {
const entry = trimmedLine.substring(2);
currentVersion[currentSection].push(entry);
} else if (
trimmedLine &&
!trimmedLine.startsWith('#') &&
!trimmedLine.startsWith('###')
) {
currentVersion.content.push(trimmedLine);
}
}
}
// 添加最后一个版本
if (currentVersion) {
versions.push(currentVersion);
}
// 后处理:如果某个版本没有分类内容,但有 content则将 content 放到 changed 中
versions.forEach((version) => {
const hasCategories =
version.added.length > 0 ||
version.changed.length > 0 ||
version.fixed.length > 0;
if (!hasCategories && version.content.length > 0) {
version.changed = version.content;
}
// 清理 content 字段
delete version.content;
});
return { versions };
}
function generateTypeScript(changelogData) {
const entries = changelogData.versions
.map((version) => {
const addedEntries = version.added
.map((entry) => ` "${entry}"`)
.join(',\n');
const changedEntries = version.changed
.map((entry) => ` "${entry}"`)
.join(',\n');
const fixedEntries = version.fixed
.map((entry) => ` "${entry}"`)
.join(',\n');
return ` {
version: "${version.version}",
date: "${version.date}",
added: [
${addedEntries || ' // 无新增内容'}
],
changed: [
${changedEntries || ' // 无变更内容'}
],
fixed: [
${fixedEntries || ' // 无修复内容'}
]
}`;
})
.join(',\n');
return `// 此文件由 scripts/convert-changelog.js 自动生成
// 请勿手动编辑
export interface ChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const changelog: ChangelogEntry[] = [
${entries}
];
export default changelog;
`;
}
function updateVersionFile(version) {
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
try {
fs.writeFileSync(versionTxtPath, version, 'utf8');
console.log(`✅ 已更新 VERSION.txt: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 VERSION.txt:`, error.message);
process.exit(1);
}
}
function updateVersionTs(version) {
const versionTsPath = path.join(process.cwd(), 'src/lib/version.ts');
try {
let content = fs.readFileSync(versionTsPath, 'utf8');
// 替换 CURRENT_VERSION 常量
const updatedContent = content.replace(
/const CURRENT_VERSION = ['"`][^'"`]+['"`];/,
`const CURRENT_VERSION = '${version}';`
);
fs.writeFileSync(versionTsPath, updatedContent, 'utf8');
console.log(`✅ 已更新 version.ts: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 version.ts:`, error.message);
process.exit(1);
}
}
function main() {
try {
const changelogPath = path.join(process.cwd(), 'CHANGELOG');
const outputPath = path.join(process.cwd(), 'src/lib/changelog.ts');
console.log('正在读取 CHANGELOG 文件...');
const changelogContent = fs.readFileSync(changelogPath, 'utf-8');
console.log('正在解析 CHANGELOG 内容...');
const changelogData = parseChangelog(changelogContent);
if (changelogData.versions.length === 0) {
console.error('❌ 未在 CHANGELOG 中找到任何版本');
process.exit(1);
}
// 获取最新版本号CHANGELOG中的第一个版本
const latestVersion = changelogData.versions[0].version;
console.log(`🔢 最新版本: ${latestVersion}`);
console.log('正在生成 TypeScript 文件...');
const tsContent = generateTypeScript(changelogData);
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, tsContent, 'utf-8');
// 检查是否在 GitHub Actions 环境中运行
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
if (isGitHubActions) {
// 在 GitHub Actions 中,更新版本文件
console.log('正在更新版本文件...');
updateVersionFile(latestVersion);
updateVersionTs(latestVersion);
} else {
// 在本地运行时,只提示但不更新版本文件
console.log('🔧 本地运行模式:跳过版本文件更新');
console.log('💡 版本文件更新将在 git tag 触发的 release 工作流中完成');
}
console.log(`✅ 成功生成 ${outputPath}`);
console.log(`📊 版本统计:`);
changelogData.versions.forEach((version) => {
console.log(
` ${version.version} (${version.date}): +${version.added.length} ~${version.changed.length} !${version.fixed.length}`
);
});
console.log('\n🎉 转换完成!');
} catch (error) {
console.error('❌ 转换失败:', error);
process.exit(1);
}
}
if (require.main === module) {
main();
}

View File

@ -0,0 +1,63 @@
#!/usr/bin/env node
/* eslint-disable */
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json
const fs = require('fs');
const path = require('path');
// 获取项目根目录
const projectRoot = path.resolve(__dirname, '..');
const publicDir = path.join(projectRoot, 'public');
const manifestPath = path.join(publicDir, 'manifest.json');
// 从环境变量获取站点名称
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
// manifest.json 模板
const manifestTemplate = {
name: siteName,
short_name: siteName,
description: '影视聚合',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#000000',
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-256x256.png',
sizes: '256x256',
type: 'image/png',
},
{
src: '/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
};
try {
// 确保 public 目录存在
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// 写入 manifest.json
fs.writeFileSync(manifestPath, JSON.stringify(manifestTemplate, null, 2));
console.log(`✅ Generated manifest.json with site name: ${siteName}`);
} catch (error) {
console.error('❌ Error generating manifest.json:', error);
process.exit(1);
}

5090
src/app/admin/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,197 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
interface BaseBody {
action?: Action;
}
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = (await request.json()) as BaseBody & Record<string, any>;
const { action } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 获取配置与存储
const adminConfig = await getConfig();
// 权限与身份校验
if (username !== process.env.USERNAME) {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
switch (action) {
case 'add': {
const { name, type, query } = body as {
name?: string;
type?: 'movie' | 'tv';
query?: string;
};
if (!name || !type || !query) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 检查是否已存在相同的查询和类型组合
if (
adminConfig.CustomCategories.some(
(c) => c.query === query && c.type === type
)
) {
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
}
adminConfig.CustomCategories.push({
name,
type,
query,
from: 'custom',
disabled: false,
});
break;
}
case 'disable': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const entry = adminConfig.CustomCategories.find(
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = true;
break;
}
case 'enable': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const entry = adminConfig.CustomCategories.find(
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = false;
break;
}
case 'delete': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const idx = adminConfig.CustomCategories.findIndex(
(c) => c.query === query && c.type === type
);
if (idx === -1)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
const entry = adminConfig.CustomCategories[idx];
if (entry.from === 'config') {
return NextResponse.json(
{ error: '该分类不可删除' },
{ status: 400 }
);
}
adminConfig.CustomCategories.splice(idx, 1);
break;
}
case 'sort': {
const { order } = body as { order?: string[] };
if (!Array.isArray(order)) {
return NextResponse.json(
{ error: '排序列表格式错误' },
{ status: 400 }
);
}
const map = new Map(
adminConfig.CustomCategories.map((c) => [`${c.query}:${c.type}`, c])
);
const newList: typeof adminConfig.CustomCategories = [];
order.forEach((key) => {
const item = map.get(key);
if (item) {
newList.push(item);
map.delete(key);
}
});
// 未在 order 中的保持原顺序
adminConfig.CustomCategories.forEach((item) => {
if (map.has(`${item.query}:${item.type}`)) newList.push(item);
});
adminConfig.CustomCategories = newList;
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 持久化到存储
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store',
},
}
);
} catch (error) {
console.error('分类管理操作失败:', error);
return NextResponse.json(
{
error: '分类管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,63 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
try {
const config = await getConfig();
const result: AdminConfigResult = {
Role: 'owner',
Config: config,
};
if (username === process.env.USERNAME) {
result.Role = 'owner';
} else {
const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.role === 'admin' && !user.banned) {
result.Role = 'admin';
} else {
return NextResponse.json(
{ error: '你是管理员吗你就访问?' },
{ status: 401 }
);
}
}
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
});
} catch (error) {
console.error('获取管理员配置失败:', error);
return NextResponse.json(
{
error: '获取管理员配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,96 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
try {
// 检查用户权限
let adminConfig = await getConfig();
// 仅站长可以修改配置文件
if (username !== process.env.USERNAME) {
return NextResponse.json(
{ error: '权限不足,只有站长可以修改配置文件' },
{ status: 401 }
);
}
// 获取请求体
const body = await request.json();
const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body;
if (!configFile || typeof configFile !== 'string') {
return NextResponse.json(
{ error: '配置文件内容不能为空' },
{ status: 400 }
);
}
// 验证 JSON 格式
try {
JSON.parse(configFile);
} catch (e) {
return NextResponse.json(
{ error: '配置文件格式错误,请检查 JSON 语法' },
{ status: 400 }
);
}
adminConfig.ConfigFile = configFile;
if (!adminConfig.ConfigSubscribtion) {
adminConfig.ConfigSubscribtion = {
URL: '',
AutoUpdate: false,
LastCheck: '',
};
}
// 更新订阅配置
if (subscriptionUrl !== undefined) {
adminConfig.ConfigSubscribtion.URL = subscriptionUrl;
}
if (autoUpdate !== undefined) {
adminConfig.ConfigSubscribtion.AutoUpdate = autoUpdate;
}
adminConfig.ConfigSubscribtion.LastCheck = lastCheckTime || '';
adminConfig = refineConfig(adminConfig);
// 更新配置文件
await db.saveAdminConfig(adminConfig);
return NextResponse.json({
success: true,
message: '配置文件更新成功',
});
} catch (error) {
console.error('更新配置文件失败:', error);
return NextResponse.json(
{
error: '更新配置文件失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,66 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
// 权限检查:仅站长可以拉取配置订阅
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json(
{ error: '权限不足,只有站长可以拉取配置订阅' },
{ status: 401 }
);
}
const { url } = await request.json();
if (!url) {
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
}
// 直接 fetch URL 获取配置内容
const response = await fetch(url);
if (!response.ok) {
return NextResponse.json(
{ error: `请求失败: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
const configContent = await response.text();
// 对 configContent 进行 base58 解码
let decodedContent;
try {
const bs58 = (await import('bs58')).default;
const decodedBytes = bs58.decode(configContent);
decodedContent = new TextDecoder().decode(decodedBytes);
} catch (decodeError) {
console.warn('Base58 解码失败', decodeError);
throw decodeError;
}
return NextResponse.json({
success: true,
configContent: decodedContent,
message: '配置拉取成功'
});
} catch (error) {
console.error('拉取配置失败:', error);
return NextResponse.json(
{ error: '拉取配置失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,136 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { promisify } from 'util';
import { gzip } from 'zlib';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { SimpleCrypto } from '@/lib/crypto';
import { db } from '@/lib/db';
import { CURRENT_VERSION } from '@/lib/version';
export const runtime = 'nodejs';
const gzipAsync = promisify(gzip);
export async function POST(req: NextRequest) {
try {
// 检查存储类型
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{ error: '不支持本地存储进行数据迁移' },
{ status: 400 }
);
}
// 验证身份和权限
const authInfo = getAuthInfoFromCookie(req);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
// 检查用户权限(只有站长可以导出数据)
if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
}
const config = await db.getAdminConfig();
if (!config) {
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
}
// 解析请求体获取密码
const { password } = await req.json();
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 });
}
// 收集所有数据
const exportData = {
timestamp: new Date().toISOString(),
serverVersion: CURRENT_VERSION,
data: {
// 管理员配置
adminConfig: config,
// 所有用户数据
userData: {} as { [username: string]: any }
}
};
// 获取所有用户
let allUsers = await db.getAllUsers();
// 添加站长用户
allUsers.push(process.env.USERNAME);
allUsers = Array.from(new Set(allUsers));
// 为每个用户收集数据
for (const username of allUsers) {
const userData = {
// 播放记录
playRecords: await db.getAllPlayRecords(username),
// 收藏夹
favorites: await db.getAllFavorites(username),
// 搜索历史
searchHistory: await db.getSearchHistory(username),
// 跳过片头片尾配置
skipConfigs: await db.getAllSkipConfigs(username),
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
password: await getUserPassword(username)
};
exportData.data.userData[username] = userData;
}
// 覆盖站长密码
exportData.data.userData[process.env.USERNAME].password = process.env.PASSWORD;
// 将数据转换为JSON字符串
const jsonData = JSON.stringify(exportData);
// 先压缩数据
const compressedData = await gzipAsync(jsonData);
// 使用提供的密码加密压缩后的数据
const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password);
// 生成文件名
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const filename = `OrangeTV-backup-${timestamp}.dat`;
// 返回加密的数据作为文件下载
return new NextResponse(encryptedData, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': encryptedData.length.toString(),
},
});
} catch (error) {
console.error('数据导出失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '导出失败' },
{ status: 500 }
);
}
}
// 辅助函数:获取用户密码(通过数据库直接访问)
async function getUserPassword(username: string): Promise<string | null> {
try {
// 使用 Redis 存储的直接访问方法
const storage = (db as any).storage;
if (storage && typeof storage.client?.get === 'function') {
const passwordKey = `u:${username}:pwd`;
const password = await storage.client.get(passwordKey);
return password;
}
return null;
} catch (error) {
console.error(`获取用户 ${username} 密码失败:`, error);
return null;
}
}

View File

@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { promisify } from 'util';
import { gunzip } from 'zlib';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { configSelfCheck, setCachedConfig } from '@/lib/config';
import { SimpleCrypto } from '@/lib/crypto';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
const gunzipAsync = promisify(gunzip);
export async function POST(req: NextRequest) {
try {
// 检查存储类型
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{ error: '不支持本地存储进行数据迁移' },
{ status: 400 }
);
}
// 验证身份和权限
const authInfo = getAuthInfoFromCookie(req);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
// 检查用户权限(只有站长可以导入数据)
if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
}
// 解析表单数据
const formData = await req.formData();
const file = formData.get('file') as File;
const password = formData.get('password') as string;
if (!file) {
return NextResponse.json({ error: '请选择备份文件' }, { status: 400 });
}
if (!password) {
return NextResponse.json({ error: '请提供解密密码' }, { status: 400 });
}
// 读取文件内容
const encryptedData = await file.text();
// 解密数据
let decryptedData: string;
try {
decryptedData = SimpleCrypto.decrypt(encryptedData, password);
} catch (error) {
return NextResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 });
}
// 解压缩数据
const compressedBuffer = Buffer.from(decryptedData, 'base64');
const decompressedBuffer = await gunzipAsync(compressedBuffer);
const decompressedData = decompressedBuffer.toString();
// 解析JSON数据
let importData: any;
try {
importData = JSON.parse(decompressedData);
} catch (error) {
return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 });
}
// 验证数据格式
if (!importData.data || !importData.data.adminConfig || !importData.data.userData) {
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 });
}
// 开始导入数据 - 先清空现有数据
await db.clearAllData();
// 导入管理员配置
importData.data.adminConfig = configSelfCheck(importData.data.adminConfig);
await db.saveAdminConfig(importData.data.adminConfig);
await setCachedConfig(importData.data.adminConfig);
// 导入用户数据
const userData = importData.data.userData;
for (const username in userData) {
const user = userData[username];
// 重新注册用户(包含密码)
if (user.password) {
await db.registerUser(username, user.password);
}
// 导入播放记录
if (user.playRecords) {
for (const [key, record] of Object.entries(user.playRecords)) {
await (db as any).storage.setPlayRecord(username, key, record);
}
}
// 导入收藏夹
if (user.favorites) {
for (const [key, favorite] of Object.entries(user.favorites)) {
await (db as any).storage.setFavorite(username, key, favorite);
}
}
// 导入搜索历史
if (user.searchHistory && Array.isArray(user.searchHistory)) {
for (const keyword of user.searchHistory.reverse()) { // 反转以保持顺序
await db.addSearchHistory(username, keyword);
}
}
// 导入跳过片头片尾配置
if (user.skipConfigs) {
for (const [key, skipConfig] of Object.entries(user.skipConfigs)) {
const [source, id] = key.split('+');
if (source && id) {
await db.setSkipConfig(username, source, id, skipConfig as any);
}
}
}
}
return NextResponse.json({
message: '数据导入成功',
importedUsers: Object.keys(userData).length,
timestamp: importData.timestamp,
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
});
} catch (error) {
console.error('数据导入失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '导入失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,57 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
const username = authInfo?.username;
const config = await getConfig();
if (username !== process.env.USERNAME) {
// 管理员
const user = config.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
// 并发刷新所有启用的直播源
const refreshPromises = (config.LiveConfig || [])
.filter(liveInfo => !liveInfo.disabled)
.map(async (liveInfo) => {
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
liveInfo.channelNumber = 0;
}
});
// 等待所有刷新任务完成
await Promise.all(refreshPromises);
// 保存配置
await db.saveAdminConfig(config);
return NextResponse.json({
success: true,
message: '直播源刷新成功',
});
} catch (error) {
console.error('直播源刷新失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '刷新失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,173 @@
/* eslint-disable no-console,no-case-declarations */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
const username = authInfo?.username;
const config = await getConfig();
if (username !== process.env.USERNAME) {
// 管理员
const user = config.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
const body = await request.json();
const { action, key, name, url, ua, epg } = body;
if (!config) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
}
// 确保 LiveConfig 存在
if (!config.LiveConfig) {
config.LiveConfig = [];
}
switch (action) {
case 'add':
// 检查是否已存在相同的 key
if (config.LiveConfig.some((l) => l.key === key)) {
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
}
const liveInfo = {
key: key as string,
name: name as string,
url: url as string,
ua: ua || '',
epg: epg || '',
from: 'custom' as 'custom' | 'config',
channelNumber: 0,
disabled: false,
}
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
console.error('刷新直播源失败:', error);
liveInfo.channelNumber = 0;
}
// 添加新的直播源
config.LiveConfig.push(liveInfo);
break;
case 'delete':
// 删除直播源
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
if (deleteIndex === -1) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
const liveSource = config.LiveConfig[deleteIndex];
if (liveSource.from === 'config') {
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
}
deleteCachedLiveChannels(key);
config.LiveConfig.splice(deleteIndex, 1);
break;
case 'enable':
// 启用直播源
const enableSource = config.LiveConfig.find((l) => l.key === key);
if (!enableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
enableSource.disabled = false;
break;
case 'disable':
// 禁用直播源
const disableSource = config.LiveConfig.find((l) => l.key === key);
if (!disableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
disableSource.disabled = true;
break;
case 'edit':
// 编辑直播源
const editSource = config.LiveConfig.find((l) => l.key === key);
if (!editSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
// 配置文件中的直播源不允许编辑
if (editSource.from === 'config') {
return NextResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
}
// 更新字段(除了 key 和 from
editSource.name = name as string;
editSource.url = url as string;
editSource.ua = ua || '';
editSource.epg = epg || '';
// 刷新频道数
try {
const nums = await refreshLiveChannels(editSource);
editSource.channelNumber = nums;
} catch (error) {
console.error('刷新直播源失败:', error);
editSource.channelNumber = 0;
}
break;
case 'sort':
// 排序直播源
const { order } = body;
if (!Array.isArray(order)) {
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
}
// 创建新的排序后的数组
const sortedLiveConfig: typeof config.LiveConfig = [];
order.forEach((key) => {
const source = config.LiveConfig?.find((l) => l.key === key);
if (source) {
sortedLiveConfig.push(source);
}
});
// 添加未在排序列表中的直播源(保持原有顺序)
config.LiveConfig.forEach((source) => {
if (!order.includes(source.key)) {
sortedLiveConfig.push(source);
}
});
config.LiveConfig = sortedLiveConfig;
break;
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 保存配置
await db.saveAdminConfig(config);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '操作失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,51 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { resetConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
if (username !== process.env.USERNAME) {
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
}
try {
await resetConfig();
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
}
);
} catch (error) {
return NextResponse.json(
{
error: '重置管理员配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,119 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const {
SiteName,
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
DoubanProxyType,
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
} = body as {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
};
// 参数校验
if (
typeof SiteName !== 'string' ||
typeof Announcement !== 'string' ||
typeof SearchDownstreamMaxPage !== 'number' ||
typeof SiteInterfaceCacheTime !== 'number' ||
typeof DoubanProxyType !== 'string' ||
typeof DoubanProxy !== 'string' ||
typeof DoubanImageProxyType !== 'string' ||
typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean' ||
typeof FluidSearch !== 'boolean'
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
const adminConfig = await getConfig();
// 权限校验
if (username !== process.env.USERNAME) {
// 管理员
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
// 更新缓存中的站点设置
adminConfig.SiteConfig = {
SiteName,
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
DoubanProxyType,
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
};
// 写入数据库
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 不缓存结果
},
}
);
} catch (error) {
console.error('更新站点配置失败:', error);
return NextResponse.json(
{
error: '更新站点配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,247 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
interface BaseBody {
action?: Action;
}
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = (await request.json()) as BaseBody & Record<string, any>;
const { action } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 获取配置与存储
const adminConfig = await getConfig();
// 权限与身份校验
if (username !== process.env.USERNAME) {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
switch (action) {
case 'add': {
const { key, name, api, detail } = body as {
key?: string;
name?: string;
api?: string;
detail?: string;
};
if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
}
adminConfig.SourceConfig.push({
key,
name,
api,
detail,
from: 'custom',
disabled: false,
});
break;
}
case 'disable': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = true;
break;
}
case 'enable': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = false;
break;
}
case 'delete': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx === -1)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
const entry = adminConfig.SourceConfig[idx];
if (entry.from === 'config') {
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
}
adminConfig.SourceConfig.splice(idx, 1);
// 检查并清理用户组和用户的权限数组
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => {
if (tag.enabledApis) {
tag.enabledApis = tag.enabledApis.filter(api => api !== key);
}
});
}
// 清理用户权限
adminConfig.UserConfig.Users.forEach(user => {
if (user.enabledApis) {
user.enabledApis = user.enabledApis.filter(api => api !== key);
}
});
break;
}
case 'batch_disable': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
}
keys.forEach(key => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) {
entry.disabled = true;
}
});
break;
}
case 'batch_enable': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
}
keys.forEach(key => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) {
entry.disabled = false;
}
});
break;
}
case 'batch_delete': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
}
// 过滤掉 from=config 的源,但不报错
const keysToDelete = keys.filter(key => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
return entry && entry.from !== 'config';
});
// 批量删除
keysToDelete.forEach(key => {
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx !== -1) {
adminConfig.SourceConfig.splice(idx, 1);
}
});
// 检查并清理用户组和用户的权限数组
if (keysToDelete.length > 0) {
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => {
if (tag.enabledApis) {
tag.enabledApis = tag.enabledApis.filter(api => !keysToDelete.includes(api));
}
});
}
// 清理用户权限
adminConfig.UserConfig.Users.forEach(user => {
if (user.enabledApis) {
user.enabledApis = user.enabledApis.filter(api => !keysToDelete.includes(api));
}
});
}
break;
}
case 'sort': {
const { order } = body as { order?: string[] };
if (!Array.isArray(order)) {
return NextResponse.json(
{ error: '排序列表格式错误' },
{ status: 400 }
);
}
const map = new Map(adminConfig.SourceConfig.map((s) => [s.key, s]));
const newList: typeof adminConfig.SourceConfig = [];
order.forEach((k) => {
const item = map.get(k);
if (item) {
newList.push(item);
map.delete(k);
}
});
// 未在 order 中的保持原顺序
adminConfig.SourceConfig.forEach((item) => {
if (map.has(item.key)) newList.push(item);
});
adminConfig.SourceConfig = newList;
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 持久化到存储
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store',
},
}
);
} catch (error) {
console.error('视频源管理操作失败:', error);
return NextResponse.json(
{
error: '视频源管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { API_CONFIG } from '@/lib/config';
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 searchKeyword = searchParams.get('q');
if (!searchKeyword) {
return new Response(
JSON.stringify({ error: '搜索关键词不能为空' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
const config = await getConfig();
const apiSites = config.SourceConfig;
// 共享状态
let streamClosed = false;
// 创建可读流
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// 辅助函数:安全地向控制器写入数据
const safeEnqueue = (data: Uint8Array) => {
try {
if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
return false;
}
controller.enqueue(data);
return true;
} catch (error) {
console.warn('Failed to enqueue data:', error);
streamClosed = true;
return false;
}
};
// 发送开始事件
const startEvent = `data: ${JSON.stringify({
type: 'start',
totalSources: apiSites.length
})}\n\n`;
if (!safeEnqueue(encoder.encode(startEvent))) {
return;
}
// 记录已完成的源数量
let completedSources = 0;
// 为每个源创建验证 Promise
const validationPromises = apiSites.map(async (site) => {
try {
// 构建搜索URL只获取第一页
const searchUrl = `${site.api}?ac=videolist&wd=${encodeURIComponent(searchKeyword)}`;
// 设置超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(searchUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json() as any;
// 检查结果是否有效
let status: 'valid' | 'no_results' | 'invalid';
if (
data &&
data.list &&
Array.isArray(data.list) &&
data.list.length > 0
) {
// 检查是否有标题包含搜索词的结果
const validResults = data.list.filter((item: any) => {
const title = item.vod_name || '';
return title.toLowerCase().includes(searchKeyword.toLowerCase());
});
if (validResults.length > 0) {
status = 'valid';
} else {
status = 'no_results';
}
} else {
status = 'no_results';
}
// 发送该源的验证结果
completedSources++;
if (!streamClosed) {
const sourceEvent = `data: ${JSON.stringify({
type: 'source_result',
source: site.key,
status
})}\n\n`;
if (!safeEnqueue(encoder.encode(sourceEvent))) {
streamClosed = true;
return;
}
}
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
console.warn(`验证失败 ${site.name}:`, error);
// 发送源错误事件
completedSources++;
if (!streamClosed) {
const errorEvent = `data: ${JSON.stringify({
type: 'source_error',
source: site.key,
status: 'invalid'
})}\n\n`;
if (!safeEnqueue(encoder.encode(errorEvent))) {
streamClosed = true;
return;
}
}
}
// 检查是否所有源都已完成
if (completedSources === apiSites.length) {
if (!streamClosed) {
// 发送最终完成事件
const completeEvent = `data: ${JSON.stringify({
type: 'complete',
completedSources
})}\n\n`;
if (safeEnqueue(encoder.encode(completeEvent))) {
try {
controller.close();
} catch (error) {
console.warn('Failed to close controller:', error);
}
}
}
}
});
// 等待所有验证完成
await Promise.allSettled(validationPromises);
},
cancel() {
streamClosed = true;
console.log('Client disconnected, cancelling validation stream');
},
});
// 返回流式响应
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@ -0,0 +1,481 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
const ACTIONS = [
'add',
'ban',
'unban',
'setAdmin',
'cancelAdmin',
'changePassword',
'deleteUser',
'updateUserApis',
'userGroup',
'updateUserGroups',
'batchUpdateUserGroups',
] as const;
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const {
targetUsername, // 目标用户名
targetPassword, // 目标用户密码(仅在添加用户时需要)
action,
} = body as {
targetUsername?: string;
targetPassword?: string;
action?: (typeof ACTIONS)[number];
};
if (!action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 用户组操作和批量操作不需要targetUsername
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
}
if (
action !== 'changePassword' &&
action !== 'deleteUser' &&
action !== 'updateUserApis' &&
action !== 'userGroup' &&
action !== 'updateUserGroups' &&
action !== 'batchUpdateUserGroups' &&
username === targetUsername
) {
return NextResponse.json(
{ error: '无法对自己进行此操作' },
{ status: 400 }
);
}
// 获取配置与存储
const adminConfig = await getConfig();
// 判定操作者角色
let operatorRole: 'owner' | 'admin';
if (username === process.env.USERNAME) {
operatorRole = 'owner';
} else {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
operatorRole = 'admin';
}
// 查找目标用户条目(用户组操作和批量操作不需要)
let targetEntry: any = null;
let isTargetAdmin = false;
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
targetEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === targetUsername
);
if (
targetEntry &&
targetEntry.role === 'owner' &&
!['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
) {
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
}
// 权限校验逻辑
isTargetAdmin = targetEntry?.role === 'admin';
}
switch (action) {
case 'add': {
if (targetEntry) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
}
if (!targetPassword) {
return NextResponse.json(
{ error: '缺少目标用户密码' },
{ status: 400 }
);
}
await db.registerUser(targetUsername!, targetPassword);
// 获取用户组信息
const { userGroup } = body as { userGroup?: string };
// 更新配置
const newUser: any = {
username: targetUsername!,
role: 'user',
};
// 如果指定了用户组添加到tags中
if (userGroup && userGroup.trim()) {
newUser.tags = [userGroup];
}
adminConfig.UserConfig.Users.push(newUser);
targetEntry =
adminConfig.UserConfig.Users[
adminConfig.UserConfig.Users.length - 1
];
break;
}
case 'ban': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (isTargetAdmin) {
// 目标是管理员
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可封禁管理员' },
{ status: 401 }
);
}
}
targetEntry.banned = true;
break;
}
case 'unban': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (isTargetAdmin) {
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可操作管理员' },
{ status: 401 }
);
}
}
targetEntry.banned = false;
break;
}
case 'setAdmin': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role === 'admin') {
return NextResponse.json(
{ error: '该用户已是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可设置管理员' },
{ status: 401 }
);
}
targetEntry.role = 'admin';
break;
}
case 'cancelAdmin': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role !== 'admin') {
return NextResponse.json(
{ error: '目标用户不是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可取消管理员' },
{ status: 401 }
);
}
targetEntry.role = 'user';
break;
}
case 'changePassword': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (!targetPassword) {
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
}
// 权限检查:不允许修改站长密码
if (targetEntry.role === 'owner') {
return NextResponse.json(
{ error: '无法修改站长密码' },
{ status: 401 }
);
}
if (
isTargetAdmin &&
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
{ error: '仅站长可修改其他管理员密码' },
{ status: 401 }
);
}
await db.changePassword(targetUsername!, targetPassword);
break;
}
case 'deleteUser': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
if (username === targetUsername) {
return NextResponse.json(
{ error: '不能删除自己' },
{ status: 400 }
);
}
if (isTargetAdmin && operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可删除管理员' },
{ status: 401 }
);
}
await db.deleteUser(targetUsername!);
// 从配置中移除用户
const userIndex = adminConfig.UserConfig.Users.findIndex(
(u) => u.username === targetUsername
);
if (userIndex > -1) {
adminConfig.UserConfig.Users.splice(userIndex, 1);
}
break;
}
case 'updateUserApis': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
const { enabledApis } = body as { enabledApis?: string[] };
// 权限检查:站长可配置所有人的采集源,管理员可配置普通用户和自己的采集源
if (
isTargetAdmin &&
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
{ error: '仅站长可配置其他管理员的采集源' },
{ status: 401 }
);
}
// 更新用户的采集源权限
if (enabledApis && enabledApis.length > 0) {
targetEntry.enabledApis = enabledApis;
} else {
// 如果为空数组或未提供,则删除该字段,表示无限制
delete targetEntry.enabledApis;
}
break;
}
case 'userGroup': {
// 用户组管理操作
const { groupAction, groupName, enabledApis } = body as {
groupAction: 'add' | 'edit' | 'delete';
groupName: string;
enabledApis?: string[];
};
if (!adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags = [];
}
switch (groupAction) {
case 'add': {
// 检查用户组是否已存在
if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
return NextResponse.json({ error: '用户组已存在' }, { status: 400 });
}
adminConfig.UserConfig.Tags.push({
name: groupName,
enabledApis: enabledApis || [],
});
break;
}
case 'edit': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
if (groupIndex === -1) {
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
}
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
break;
}
case 'delete': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
if (groupIndex === -1) {
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
}
// 查找使用该用户组的所有用户
const affectedUsers: string[] = [];
adminConfig.UserConfig.Users.forEach(user => {
if (user.tags && user.tags.includes(groupName)) {
affectedUsers.push(user.username);
// 从用户的tags中移除该用户组
user.tags = user.tags.filter(tag => tag !== groupName);
// 如果用户没有其他标签了删除tags字段
if (user.tags.length === 0) {
delete user.tags;
}
}
});
// 删除用户组
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
// 记录删除操作的影响
console.log(`删除用户组 "${groupName}",影响用户: ${affectedUsers.length > 0 ? affectedUsers.join(', ') : '无'}`);
break;
}
default:
return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 });
}
break;
}
case 'updateUserGroups': {
if (!targetEntry) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
}
const { userGroups } = body as { userGroups: string[] };
// 权限检查:站长可配置所有人的用户组,管理员可配置普通用户和自己的用户组
if (
isTargetAdmin &&
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
}
// 更新用户的用户组
if (userGroups && userGroups.length > 0) {
targetEntry.tags = userGroups;
} else {
// 如果为空数组或未提供,则删除该字段,表示无用户组
delete targetEntry.tags;
}
break;
}
case 'batchUpdateUserGroups': {
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 });
}
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
if (operatorRole !== 'owner') {
for (const targetUsername of usernames) {
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
}
}
}
// 批量更新用户组
for (const targetUsername of usernames) {
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
if (targetUser) {
if (userGroups && userGroups.length > 0) {
targetUser.tags = userGroups;
} else {
// 如果为空数组或未提供,则删除该字段,表示无用户组
delete targetUser.tags;
}
}
}
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 将更新后的配置写入数据库
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
}
);
} catch (error) {
console.error('用户管理操作失败:', error);
return NextResponse.json(
{
error: '用户管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

115
src/app/api/avatar/route.ts Normal file
View File

@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取用户头像
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const targetUser = searchParams.get('user') || authInfo.username;
// 只允许获取自己的头像,管理员和站长可以获取任何用户的头像
const canAccess = targetUser === authInfo.username ||
authInfo.role === 'admin' ||
authInfo.role === 'owner';
if (!canAccess) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
const avatar = await db.getUserAvatar(targetUser);
if (!avatar) {
return NextResponse.json({ avatar: null });
}
return NextResponse.json({ avatar });
} catch (error) {
console.error('获取头像失败:', error);
return NextResponse.json({ error: '获取头像失败' }, { status: 500 });
}
}
// 上传用户头像
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { avatar, targetUser } = body;
if (!avatar) {
return NextResponse.json({ error: '头像数据不能为空' }, { status: 400 });
}
// 验证Base64格式
if (!avatar.startsWith('data:image/')) {
return NextResponse.json({ error: '无效的图片格式' }, { status: 400 });
}
// 检查文件大小Base64编码后大约增加33%2MB的限制
const base64Data = avatar.split(',')[1];
const sizeInBytes = (base64Data.length * 3) / 4;
if (sizeInBytes > 2 * 1024 * 1024) {
return NextResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
}
const userToUpdate = targetUser || authInfo.username;
// 只允许更新自己的头像,管理员和站长可以更新任何用户的头像
const canUpdate = userToUpdate === authInfo.username ||
authInfo.role === 'admin' ||
authInfo.role === 'owner';
if (!canUpdate) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await db.setUserAvatar(userToUpdate, avatar);
return NextResponse.json({ success: true, message: '头像上传成功' });
} catch (error) {
console.error('上传头像失败:', error);
return NextResponse.json({ error: '上传头像失败' }, { status: 500 });
}
}
// 删除用户头像
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const targetUser = searchParams.get('user') || authInfo.username;
// 只允许删除自己的头像,管理员和站长可以删除任何用户的头像
const canDelete = targetUser === authInfo.username ||
authInfo.role === 'admin' ||
authInfo.role === 'owner';
if (!canDelete) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await db.deleteUserAvatar(targetUser);
return NextResponse.json({ success: true, message: '头像删除成功' });
} catch (error) {
console.error('删除头像失败:', error);
return NextResponse.json({ error: '删除头像失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,62 @@
/* eslint-disable no-console*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
// 不支持 localstorage 模式
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储模式修改密码',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const { newPassword } = body;
// 获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 验证新密码
if (!newPassword || typeof newPassword !== 'string') {
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
}
const username = authInfo.username;
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME
if (username === process.env.USERNAME) {
return NextResponse.json(
{ error: '站长不能通过此接口修改密码' },
{ status: 403 }
);
}
// 修改密码
await db.changePassword(username, newPassword);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('修改密码失败:', error);
return NextResponse.json(
{
error: '修改密码失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

256
src/app/api/cron/route.ts Normal file
View File

@ -0,0 +1,256 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live';
import { SearchResult } from '@/lib/types';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
console.log(request.url);
try {
console.log('Cron job triggered:', new Date().toISOString());
cronJob();
return NextResponse.json({
success: true,
message: 'Cron job executed successfully',
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Cron job failed:', error);
return NextResponse.json(
{
success: false,
message: 'Cron job failed',
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}
async function cronJob() {
await refreshConfig();
await refreshAllLiveChannels();
await refreshRecordAndFavorites();
}
async function refreshAllLiveChannels() {
const config = await getConfig();
// 并发刷新所有启用的直播源
const refreshPromises = (config.LiveConfig || [])
.filter(liveInfo => !liveInfo.disabled)
.map(async (liveInfo) => {
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
console.error(`刷新直播源失败 [${liveInfo.name || liveInfo.key}]:`, error);
liveInfo.channelNumber = 0;
}
});
// 等待所有刷新任务完成
await Promise.all(refreshPromises);
// 保存配置
await db.saveAdminConfig(config);
}
async function refreshConfig() {
let config = await getConfig();
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {
try {
const response = await fetch(config.ConfigSubscribtion.URL);
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const configContent = await response.text();
// 对 configContent 进行 base58 解码
let decodedContent;
try {
const bs58 = (await import('bs58')).default;
const decodedBytes = bs58.decode(configContent);
decodedContent = new TextDecoder().decode(decodedBytes);
} catch (decodeError) {
console.warn('Base58 解码失败:', decodeError);
throw decodeError;
}
try {
JSON.parse(decodedContent);
} catch (e) {
throw new Error('配置文件格式错误,请检查 JSON 语法');
}
config.ConfigFile = decodedContent;
config.ConfigSubscribtion.LastCheck = new Date().toISOString();
config = refineConfig(config);
await db.saveAdminConfig(config);
} catch (e) {
console.error('刷新配置失败:', e);
}
} else {
console.log('跳过刷新:未配置订阅地址或自动更新');
}
}
async function refreshRecordAndFavorites() {
try {
const users = await db.getAllUsers();
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
users.push(process.env.USERNAME);
}
// 函数级缓存key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
const detailCache = new Map<string, Promise<SearchResult | null>>();
// 获取详情 Promise带缓存和错误处理
const getDetail = async (
source: string,
id: string,
fallbackTitle: string
): Promise<SearchResult | null> => {
const key = `${source}+${id}`;
let promise = detailCache.get(key);
if (!promise) {
promise = fetchVideoDetail({
source,
id,
fallbackTitle: fallbackTitle.trim(),
})
.then((detail) => {
// 成功时才缓存结果
const successPromise = Promise.resolve(detail);
detailCache.set(key, successPromise);
return detail;
})
.catch((err) => {
console.error(`获取视频详情失败 (${source}+${id}):`, err);
return null;
});
}
return promise;
};
for (const user of users) {
console.log(`开始处理用户: ${user}`);
// 播放记录
try {
const playRecords = await db.getAllPlayRecords(user);
const totalRecords = Object.keys(playRecords).length;
let processedRecords = 0;
for (const [key, record] of Object.entries(playRecords)) {
try {
const [source, id] = key.split('+');
if (!source || !id) {
console.warn(`跳过无效的播放记录键: ${key}`);
continue;
}
const detail = await getDetail(source, id, record.title);
if (!detail) {
console.warn(`跳过无法获取详情的播放记录: ${key}`);
continue;
}
const episodeCount = detail.episodes?.length || 0;
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
await db.savePlayRecord(user, source, id, {
title: detail.title || record.title,
source_name: record.source_name,
cover: detail.poster || record.cover,
index: record.index,
total_episodes: episodeCount,
play_time: record.play_time,
year: detail.year || record.year,
total_time: record.total_time,
save_time: record.save_time,
search_title: record.search_title,
});
console.log(
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
);
}
processedRecords++;
} catch (err) {
console.error(`处理播放记录失败 (${key}):`, err);
// 继续处理下一个记录
}
}
console.log(`播放记录处理完成: ${processedRecords}/${totalRecords}`);
} catch (err) {
console.error(`获取用户播放记录失败 (${user}):`, err);
}
// 收藏
try {
let favorites = await db.getAllFavorites(user);
favorites = Object.fromEntries(
Object.entries(favorites).filter(([_, fav]) => fav.origin !== 'live')
);
const totalFavorites = Object.keys(favorites).length;
let processedFavorites = 0;
for (const [key, fav] of Object.entries(favorites)) {
try {
const [source, id] = key.split('+');
if (!source || !id) {
console.warn(`跳过无效的收藏键: ${key}`);
continue;
}
const favDetail = await getDetail(source, id, fav.title);
if (!favDetail) {
console.warn(`跳过无法获取详情的收藏: ${key}`);
continue;
}
const favEpisodeCount = favDetail.episodes?.length || 0;
if (favEpisodeCount > 0 && favEpisodeCount !== fav.total_episodes) {
await db.saveFavorite(user, source, id, {
title: favDetail.title || fav.title,
source_name: fav.source_name,
cover: favDetail.poster || fav.cover,
year: favDetail.year || fav.year,
total_episodes: favEpisodeCount,
save_time: fav.save_time,
search_title: fav.search_title,
});
console.log(
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
);
}
processedFavorites++;
} catch (err) {
console.error(`处理收藏失败 (${key}):`, err);
// 继续处理下一个收藏
}
}
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
} catch (err) {
console.error(`获取用户收藏失败 (${user}):`, err);
}
}
console.log('刷新播放记录/收藏任务完成');
} catch (err) {
console.error('刷新播放记录/收藏任务启动失败', err);
}
}

109
src/app/api/danmu/route.ts Normal file
View File

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取弹幕
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const videoId = searchParams.get('videoId');
if (!videoId) {
return NextResponse.json({ error: '视频ID不能为空' }, { status: 400 });
}
const danmuList = await db.getDanmu(videoId);
// 转换为artplayer-plugin-danmuku所需的格式
const formattedDanmu = danmuList.map((item) => ({
text: item.text,
color: item.color,
mode: item.mode,
time: item.time,
border: false,
size: 25
}));
return NextResponse.json(formattedDanmu);
} catch (error) {
console.error('获取弹幕失败:', error);
return NextResponse.json({ error: '获取弹幕失败' }, { status: 500 });
}
}
// 发送弹幕
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { videoId, text, color, mode, time } = body;
if (!videoId || !text) {
return NextResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
}
// 验证弹幕内容长度
if (text.length > 100) {
return NextResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
}
// 过滤敏感内容(可以扩展)
const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词
const hasSensitiveWord = sensitiveWords.some(word => text.includes(word));
if (hasSensitiveWord) {
return NextResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
}
const danmuData = {
text: text.trim(),
color: color || '#FFFFFF',
mode: mode || 0,
time: time || 0,
timestamp: Date.now()
};
await db.saveDanmu(videoId, authInfo.username, danmuData);
return NextResponse.json({ success: true, message: '弹幕发送成功' });
} catch (error) {
console.error('发送弹幕失败:', error);
return NextResponse.json({ error: '发送弹幕失败' }, { status: 500 });
}
}
// 删除弹幕(管理员功能)
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 只有管理员和站长可以删除弹幕
if (authInfo.role !== 'admin' && authInfo.role !== 'owner') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const videoId = searchParams.get('videoId');
const danmuId = searchParams.get('danmuId');
if (!videoId || !danmuId) {
return NextResponse.json({ error: '视频ID和弹幕ID不能为空' }, { status: 400 });
}
await db.deleteDanmu(videoId, danmuId);
return NextResponse.json({ success: true, message: '弹幕删除成功' });
} catch (error) {
console.error('删除弹幕失败:', error);
return NextResponse.json({ error: '删除弹幕失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
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 id = searchParams.get('id');
const sourceCode = searchParams.get('source');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
}
try {
const apiSites = await getAvailableApiSites(authInfo.username);
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
}
const result = await getDetailFromApi(apiSite, id);
const cacheTime = await getCacheTime();
return NextResponse.json(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',
},
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,100 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
card_subtitle: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind') || 'movie';
const category = searchParams.get('category');
const type = searchParams.get('type');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
// 验证参数
if (!kind || !category || !type) {
return NextResponse.json(
{ error: '缺少必要参数: kind 或 category 或 type' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(kind)) {
return NextResponse.json(
{ error: 'kind 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageLimit < 1 || pageLimit > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData<DoubanCategoryApiResponse>(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
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',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanResult } from '@/lib/types';
interface DoubanRecommendApiResponse {
total: number;
items: Array<{
id: string;
title: string;
year: string;
type: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
const category =
searchParams.get('category') === 'all' ? '' : searchParams.get('category');
const format =
searchParams.get('format') === 'all' ? '' : searchParams.get('format');
const region =
searchParams.get('region') === 'all' ? '' : searchParams.get('region');
const year =
searchParams.get('year') === 'all' ? '' : searchParams.get('year');
const platform =
searchParams.get('platform') === 'all' ? '' : searchParams.get('platform');
const sort = searchParams.get('sort') === 'T' ? '' : searchParams.get('sort');
const label =
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
if (!kind) {
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
}
const selectedCategories = { 类型: category } as any;
if (format) {
selectedCategories['形式'] = format;
}
if (region) {
selectedCategories['地区'] = region;
}
const tags = [] as Array<string>;
if (category) {
tags.push(category);
}
if (!category && format) {
tags.push(format);
}
if (label) {
tags.push(label);
}
if (region) {
tags.push(region);
}
if (year) {
tags.push(year);
}
if (platform) {
tags.push(platform);
}
const baseUrl = `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
const params = new URLSearchParams();
params.append('refresh', '0');
params.append('start', pageStart.toString());
params.append('count', pageLimit.toString());
params.append('selected_categories', JSON.stringify(selectedCategories));
params.append('uncollect', 'false');
params.append('score_range', '0,10');
params.append('tags', tags.join(','));
if (sort) {
params.append('sort', sort);
}
const target = `${baseUrl}?${params.toString()}`;
console.log(target);
try {
const doubanData = await fetchDoubanData<DoubanRecommendApiResponse>(
target
);
const list = doubanData.items
.filter((item) => item.type == 'movie' || item.type == 'tv')
.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
year: item.year,
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
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',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}

177
src/app/api/douban/route.ts Normal file
View File

@ -0,0 +1,177 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanApiResponse {
subjects: Array<{
id: string;
title: string;
cover: string;
rate: string;
}>;
}
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const type = searchParams.get('type');
const tag = searchParams.get('tag');
const pageSize = parseInt(searchParams.get('pageSize') || '16');
const pageStart = parseInt(searchParams.get('pageStart') || '0');
// 验证参数
if (!type || !tag) {
return NextResponse.json(
{ error: '缺少必要参数: type 或 tag' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(type)) {
return NextResponse.json(
{ error: 'type 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
if (tag === 'top250') {
return handleTop250(pageStart);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData<DoubanApiResponse>(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
year: '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
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',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
function handleTop250(pageStart: number) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
// 直接使用 fetch 获取 HTML 页面
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
},
};
return fetch(target, fetchOptions)
.then(async (fetchResponse) => {
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
// 获取 HTML 内容
const html = await fetchResponse.text();
// 通过正则同时捕获影片 id、标题、封面以及评分
const moviePattern =
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const id = match[1];
const title = match[2];
const cover = match[3];
const rate = match[4] || '';
// 处理图片 URL确保使用 HTTPS
const processedCover = cover.replace(/^http:/, 'https:');
movies.push({
id: id,
title: title,
poster: processedCover,
rate: rate,
year: '',
});
}
const apiResponse: DoubanResult = {
code: 200,
message: '获取成功',
list: movies,
};
const cacheTime = await getCacheTime();
return NextResponse.json(apiResponse, {
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',
},
});
})
.catch((error) => {
clearTimeout(timeoutId);
return NextResponse.json(
{
error: '获取豆瓣 Top250 数据失败',
details: (error as Error).message,
},
{ status: 500 }
);
});
}

View File

@ -0,0 +1,199 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { Favorite } from '@/lib/types';
export const runtime = 'nodejs';
/**
* GET /api/favorites
*
*
* 1. queryRecord<string, Favorite>
* 2. key=source+idFavorite | null
*/
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
// 查询单条收藏
if (key) {
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
const fav = await db.getFavorite(authInfo.username, source, id);
return NextResponse.json(fav, { status: 200 });
}
// 查询全部收藏
const favorites = await db.getAllFavorites(authInfo.username);
return NextResponse.json(favorites, { status: 200 });
} catch (err) {
console.error('获取收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/favorites
* body: { key: string; favorite: Favorite }
*/
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, favorite }: { key: string; favorite: Favorite } = body;
if (!key || !favorite) {
return NextResponse.json(
{ error: 'Missing key or favorite' },
{ status: 400 }
);
}
// 验证必要字段
if (!favorite.title || !favorite.source_name) {
return NextResponse.json(
{ error: 'Invalid favorite data' },
{ status: 400 }
);
}
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
const finalFavorite = {
...favorite,
save_time: favorite.save_time ?? Date.now(),
} as Favorite;
await db.saveFavorite(authInfo.username, source, id, finalFavorite);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('保存收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* DELETE /api/favorites
*
* 1. query ->
* 2. key=source+id ->
*/
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const username = authInfo.username;
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (key) {
// 删除单条
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.deleteFavorite(username, source, id);
} else {
// 清空全部
const all = await db.getAllFavorites(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deleteFavorite(username, s, i);
})
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
// OrionTV 兼容接口
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('url');
if (!imageUrl) {
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
}
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: 'https://movie.douban.com/',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
},
});
if (!imageResponse.ok) {
return NextResponse.json(
{ error: imageResponse.statusText },
{ status: imageResponse.status }
);
}
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) {
return NextResponse.json(
{ error: 'Image response has no body' },
{ status: 500 }
);
}
// 创建响应头
const headers = new Headers();
if (contentType) {
headers.set('Content-Type', contentType);
}
// 设置缓存头(可选)
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); // 缓存半年
headers.set('CDN-Cache-Control', 'public, s-maxage=15720000');
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
headers.set('Netlify-Vary', 'query');
// 直接返回图片流
return new Response(imageResponse.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sourceKey = searchParams.get('source');
if (!sourceKey) {
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
}
const channelData = await getCachedLiveChannels(sourceKey);
if (!channelData) {
return NextResponse.json({ error: '频道信息未找到' }, { status: 404 });
}
return NextResponse.json({
success: true,
data: channelData.channels
});
} catch (error) {
return NextResponse.json(
{ error: '获取频道信息失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sourceKey = searchParams.get('source');
const tvgId = searchParams.get('tvgId');
if (!sourceKey) {
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
}
if (!tvgId) {
return NextResponse.json({ error: '缺少频道tvg-id参数' }, { status: 400 });
}
const channelData = await getCachedLiveChannels(sourceKey);
if (!channelData) {
// 频道信息未找到时返回空的节目单数据
return NextResponse.json({
success: true,
data: {
tvgId,
source: sourceKey,
epgUrl: '',
programs: []
}
});
}
// 从epgs字段中获取对应tvgId的节目单信息
const epgData = channelData.epgs[tvgId] || [];
return NextResponse.json({
success: true,
data: {
tvgId,
source: sourceKey,
epgUrl: channelData.epgUrl,
programs: epgData
}
});
} catch (error) {
return NextResponse.json(
{ error: '获取节目单信息失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const source = searchParams.get('OrangeTV-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch', message: response.statusText }, { status: 500 });
}
const contentType = response.headers.get('Content-Type');
if (response.body) {
response.body.cancel();
}
if (contentType?.includes('video/mp4')) {
return NextResponse.json({ success: true, type: 'mp4' }, { status: 200 });
}
if (contentType?.includes('video/x-flv')) {
return NextResponse.json({ success: true, type: 'flv' }, { status: 200 });
}
return NextResponse.json({ success: true, type: 'm3u8' }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch', message: error }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
console.log(request.url)
try {
const config = await getConfig();
if (!config) {
return NextResponse.json({ error: '配置未找到' }, { status: 404 });
}
// 过滤出所有非 disabled 的直播源
const liveSources = (config.LiveConfig || []).filter(source => !source.disabled);
return NextResponse.json({
success: true,
data: liveSources
});
} catch (error) {
console.error('获取直播源失败:', error);
return NextResponse.json(
{ error: '获取直播源失败' },
{ status: 500 }
);
}
}

242
src/app/api/login/route.ts Normal file
View File

@ -0,0 +1,242 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 读取存储类型环境变量,默认 localstorage
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| 'kvrocks'
| undefined) || 'localstorage';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie带签名
async function generateAuthCookie(
username?: string,
password?: string,
role?: 'owner' | 'admin' | 'user',
includePassword = false
): Promise<string> {
const authData: any = { role: role || 'user' };
// 只在需要时包含 password
if (includePassword && password) {
authData.password = password;
}
if (username && process.env.PASSWORD) {
authData.username = username;
// 使用密码作为密钥对用户名进行签名
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
}
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
if (STORAGE_TYPE === 'localstorage') {
const envPassword = process.env.PASSWORD;
// 未配置 PASSWORD 时直接放行
if (!envPassword) {
const response = NextResponse.json({ ok: true });
// 清除可能存在的认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}
const { password } = await req.json();
if (typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
if (password !== envPassword) {
return NextResponse.json(
{ ok: false, error: '密码错误' },
{ status: 401 }
);
}
// 验证成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
undefined,
password,
'user',
true
); // localstorage 模式包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}
// 数据库 / redis 模式——校验用户名并尝试连接数据库
const { username, password, machineCode } = await req.json();
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
// 可能是站长,直接读环境变量
if (
username === process.env.USERNAME &&
password === process.env.PASSWORD
) {
// 验证成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
username,
password,
'owner',
false
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
} else if (username === process.env.USERNAME) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
}
const config = await getConfig();
const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.banned) {
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
}
// 校验用户密码
try {
const pass = await db.verifyUser(username, password);
if (!pass) {
return NextResponse.json(
{ error: '用户名或密码错误' },
{ status: 401 }
);
}
// 检查机器码绑定
const boundMachineCode = await db.getUserMachineCode(username);
if (boundMachineCode) {
// 用户已绑定机器码,需要验证
if (!machineCode) {
return NextResponse.json({
error: '该账户已绑定设备,请提供机器码',
requireMachineCode: true
}, { status: 403 });
}
if (machineCode.toUpperCase() !== boundMachineCode.toUpperCase()) {
return NextResponse.json({
error: '机器码不匹配,此账户只能在绑定的设备上使用',
machineCodeMismatch: true
}, { status: 403 });
}
} else if (machineCode) {
// 用户未绑定机器码,但提供了机器码,检查是否被其他用户绑定
const codeOwner = await db.isMachineCodeBound(machineCode);
if (codeOwner && codeOwner !== username) {
return NextResponse.json({
error: `该机器码已被用户 ${codeOwner} 绑定`,
machineCodeTaken: true
}, { status: 409 });
}
}
// 验证成功设置认证cookie
const response = NextResponse.json({
ok: true,
machineCodeBound: !!boundMachineCode,
username: username
});
const cookieValue = await generateAuthCookie(
username,
password,
user?.role || 'user',
false
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
} catch (err) {
console.error('数据库验证失败', err);
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
}
} catch (error) {
console.error('登录接口异常', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export async function POST() {
const response = NextResponse.json({ ok: true });
// 清除认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}

View File

@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取用户机器码信息
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
// 管理员获取所有用户的机器码信息
if (action === 'list' && (authInfo.role === 'admin' || authInfo.role === 'owner')) {
const machineCodeUsers = await db.getMachineCodeUsers();
return NextResponse.json({ users: machineCodeUsers });
}
// 获取当前用户的机器码
const userMachineCode = await db.getUserMachineCode(authInfo.username);
return NextResponse.json({
machineCode: userMachineCode,
isBound: !!userMachineCode
});
} catch (error) {
console.error('获取机器码信息失败:', error);
return NextResponse.json({ error: '获取机器码信息失败' }, { status: 500 });
}
}
// 绑定机器码
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { machineCode, deviceInfo, action } = body;
// 管理员操作:解绑用户机器码
if (action === 'unbind' && (authInfo.role === 'admin' || authInfo.role === 'owner')) {
const { targetUser } = body;
if (!targetUser) {
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 });
}
await db.deleteUserMachineCode(targetUser);
return NextResponse.json({ success: true, message: '机器码解绑成功' });
}
// 用户绑定机器码
if (!machineCode) {
return NextResponse.json({ error: '机器码不能为空' }, { status: 400 });
}
// 检查机器码是否已被绑定
const boundUser = await db.isMachineCodeBound(machineCode);
if (boundUser && boundUser !== authInfo.username) {
return NextResponse.json({
error: `该机器码已被用户 ${boundUser} 绑定,请联系管理员处理`,
boundUser
}, { status: 409 });
}
// 检查用户是否已绑定其他机器码
const existingMachineCode = await db.getUserMachineCode(authInfo.username);
if (existingMachineCode && existingMachineCode !== machineCode) {
return NextResponse.json({
error: '您已绑定其他设备,如需更换请联系管理员',
currentMachineCode: existingMachineCode
}, { status: 409 });
}
// 绑定机器码
await db.setUserMachineCode(authInfo.username, machineCode, deviceInfo);
return NextResponse.json({
success: true,
message: '机器码绑定成功',
machineCode
});
} catch (error) {
console.error('绑定机器码失败:', error);
return NextResponse.json({ error: '绑定机器码失败' }, { status: 500 });
}
}
// 解绑机器码(用户自己解绑)
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 检查是否有绑定的机器码
const existingMachineCode = await db.getUserMachineCode(authInfo.username);
if (!existingMachineCode) {
return NextResponse.json({ error: '您还未绑定任何机器码' }, { status: 400 });
}
// 解绑机器码
await db.deleteUserMachineCode(authInfo.username);
return NextResponse.json({
success: true,
message: '机器码解绑成功'
});
} catch (error) {
console.error('解绑机器码失败:', error);
return NextResponse.json({ error: '解绑机器码失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,168 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { PlayRecord } from '@/lib/types';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const records = await db.getAllPlayRecords(authInfo.username);
return NextResponse.json(records, { status: 200 });
} catch (err) {
console.error('获取播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, record }: { key: string; record: PlayRecord } = body;
if (!key || !record) {
return NextResponse.json(
{ error: 'Missing key or record' },
{ status: 400 }
);
}
// 验证播放记录数据
if (!record.title || !record.source_name || record.index < 1) {
return NextResponse.json(
{ error: 'Invalid record data' },
{ status: 400 }
);
}
// 从key中解析source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
const finalRecord = {
...record,
save_time: record.save_time ?? Date.now(),
} as PlayRecord;
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('保存播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const username = authInfo.username;
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (key) {
// 如果提供了 key删除单条播放记录
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.deletePlayRecord(username, source, id);
} else {
// 未提供 key则清空全部播放记录
// 目前 DbManager 没有对应方法,这里直接遍历删除
const all = await db.getAllPlayRecords(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deletePlayRecord(username, s, i);
})
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,47 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const source = searchParams.get('OrangeTV-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
console.log(decodedUrl);
const response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
const keyData = await response.arrayBuffer();
return new Response(keyData, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Cache-Control': 'public, max-age=3600'
},
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('url');
const source = searchParams.get('OrangeTV-source');
if (!imageUrl) {
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
const ua = liveSource?.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(imageUrl);
const imageResponse = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
headers: {
'User-Agent': ua,
},
});
if (!imageResponse.ok) {
return NextResponse.json(
{ error: imageResponse.statusText },
{ status: imageResponse.status }
);
}
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) {
return NextResponse.json(
{ error: 'Image response has no body' },
{ status: 500 }
);
}
// 创建响应头
const headers = new Headers();
if (contentType) {
headers.set('Content-Type', contentType);
}
// 设置缓存头
headers.set('Cache-Control', 'public, max-age=86400, s-maxage=86400'); // 缓存一天
// 直接返回图片流
return new Response(imageResponse.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,181 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
import { getBaseUrl, resolveUrl } from "@/lib/live";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const allowCORS = searchParams.get('allowCORS') === 'true';
const source = searchParams.get('OrangeTV-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
let response: Response | null = null;
let responseUsed = false;
try {
const decodedUrl = decodeURIComponent(url);
response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
}
const contentType = response.headers.get('Content-Type') || '';
// rewrite m3u8
if (contentType.toLowerCase().includes('mpegurl') || contentType.toLowerCase().includes('octet-stream')) {
// 获取最终的响应URL处理重定向后的URL
const finalUrl = response.url;
const m3u8Content = await response.text();
responseUsed = true; // 标记 response 已被使用
// 使用最终的响应URL作为baseUrl而不是原始的请求URL
const baseUrl = getBaseUrl(finalUrl);
// 重写 M3U8 内容
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return new Response(modifiedContent, { headers });
}
// just proxy
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
// 直接返回视频流
return new Response(response.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
} finally {
// 确保 response 被正确关闭以释放资源
if (response && !responseUsed) {
try {
response.body?.cancel();
} catch (error) {
// 忽略关闭时的错误
console.warn('Failed to close response body:', error);
}
}
}
}
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
// 从 referer 头提取协议信息
const referer = req.headers.get('referer');
let protocol = 'http';
if (referer) {
try {
const refererUrl = new URL(referer);
protocol = refererUrl.protocol.replace(':', '');
} catch (error) {
// ignore
}
}
const host = req.headers.get('host');
const proxyBase = `${protocol}://${host}/api/proxy`;
const lines = content.split('\n');
const rewrittenLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
// 处理 TS 片段 URL 和其他媒体文件
if (line && !line.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, line);
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
rewrittenLines.push(proxyUrl);
continue;
}
// 处理 EXT-X-MAP 标签中的 URI
if (line.startsWith('#EXT-X-MAP:')) {
line = rewriteMapUri(line, baseUrl, proxyBase);
}
// 处理 EXT-X-KEY 标签中的 URI
if (line.startsWith('#EXT-X-KEY:')) {
line = rewriteKeyUri(line, baseUrl, proxyBase);
}
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
if (line.startsWith('#EXT-X-STREAM-INF:')) {
rewrittenLines.push(line);
// 下一行通常是 M3U8 URL
if (i + 1 < lines.length) {
i++;
const nextLine = lines[i].trim();
if (nextLine && !nextLine.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, nextLine);
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
rewrittenLines.push(proxyUrl);
} else {
rewrittenLines.push(nextLine);
}
}
continue;
}
rewrittenLines.push(line);
}
return rewrittenLines.join('\n');
}
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}

View File

@ -0,0 +1,142 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const source = searchParams.get('OrangeTV-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
let response: Response | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
try {
const decodedUrl = decodeURIComponent(url);
response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
const headers = new Headers();
headers.set('Content-Type', 'video/mp2t');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const contentLength = response.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
// 使用流式传输,避免占用内存
const stream = new ReadableStream({
start(controller) {
if (!response?.body) {
controller.close();
return;
}
reader = response.body.getReader();
const isCancelled = false;
function pump() {
if (isCancelled || !reader) {
return;
}
reader.read().then(({ done, value }) => {
if (isCancelled) {
return;
}
if (done) {
controller.close();
cleanup();
return;
}
controller.enqueue(value);
pump();
}).catch((error) => {
if (!isCancelled) {
controller.error(error);
cleanup();
}
});
}
function cleanup() {
if (reader) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
}
pump();
},
cancel() {
// 当流被取消时,确保释放所有资源
if (reader) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略取消时的错误
}
}
}
});
return new Response(stream, { headers });
} catch (error) {
// 确保在错误情况下也释放资源
if (reader) {
try {
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
} catch (e) {
// 忽略错误
}
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略错误
}
}
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
}

View File

@ -0,0 +1,248 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}
console.log('Proxy video request for URL:', url);
let response: Response | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
try {
const decodedUrl = decodeURIComponent(url);
console.log('Decoded URL:', decodedUrl);
// 为短剧视频文件设置合适的请求头避免403错误
const headers: Record<string, string> = {
'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',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'identity',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Sec-Fetch-Dest': 'video',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
};
// 对于夸克网盘等,设置更精确的请求头
if (decodedUrl.includes('quark.cn') || decodedUrl.includes('drive.quark.cn')) {
headers['Referer'] = 'https://pan.quark.cn/';
headers['Origin'] = 'https://pan.quark.cn';
// 移除可能导致问题的header
delete headers['Sec-Fetch-Dest'];
delete headers['Sec-Fetch-Mode'];
delete headers['Sec-Fetch-Site'];
} else if (decodedUrl.includes('dl-c-')) {
// 对于CDN链接使用更简单的请求头
headers['Referer'] = '';
delete headers['Origin'];
}
// 处理Range请求支持视频拖拽播放
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
headers['Range'] = rangeHeader;
}
response = await fetch(decodedUrl, {
headers,
// 添加超时设置
signal: AbortSignal.timeout(30000), // 30秒超时
});
if (!response.ok) {
console.error(`Failed to fetch video: ${response.status} ${response.statusText}`);
console.error('Request headers were:', JSON.stringify(headers, null, 2));
// 返回具有正确CORS头的错误响应
const errorHeaders = new Headers();
errorHeaders.set('Access-Control-Allow-Origin', '*');
errorHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
errorHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization');
return NextResponse.json({
error: `Failed to fetch video: ${response.status}`,
details: response.statusText,
url: decodedUrl
}, {
status: response.status >= 400 ? response.status : 500,
headers: errorHeaders
});
}
console.log(`Successfully fetched video: ${response.status} ${response.statusText}`);
const responseHeaders = new Headers();
// 设置内容类型
const contentType = response.headers.get('content-type');
if (contentType) {
responseHeaders.set('Content-Type', contentType);
} else {
// 默认为MP4
responseHeaders.set('Content-Type', 'video/mp4');
}
// 完整的CORS头设置
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With');
responseHeaders.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
responseHeaders.set('Access-Control-Allow-Credentials', 'false');
// 支持Range请求
responseHeaders.set('Accept-Ranges', 'bytes');
// 传递内容长度和Range响应头
const contentLength = response.headers.get('content-length');
if (contentLength) {
responseHeaders.set('Content-Length', contentLength);
}
const contentRange = response.headers.get('content-range');
if (contentRange) {
responseHeaders.set('Content-Range', contentRange);
}
// 缓存控制
responseHeaders.set('Cache-Control', 'public, max-age=3600, must-revalidate');
// 使用流式传输,支持大文件播放
const stream = new ReadableStream({
start(controller) {
if (!response?.body) {
controller.close();
return;
}
reader = response.body.getReader();
let isCancelled = false;
function pump() {
if (isCancelled || !reader) {
return;
}
reader.read().then(({ done, value }) => {
if (isCancelled) {
return;
}
if (done) {
controller.close();
cleanup();
return;
}
try {
controller.enqueue(value);
pump();
} catch (error) {
if (!isCancelled) {
console.error('Stream error:', error);
controller.error(error);
cleanup();
}
}
}).catch((error) => {
if (!isCancelled) {
console.error('Reader error:', error);
controller.error(error);
cleanup();
}
});
}
function cleanup() {
isCancelled = true;
if (reader && reader.releaseLock) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
}
pump();
},
cancel() {
// 当流被取消时,确保释放所有资源
if (reader && reader.releaseLock) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略取消时的错误
}
}
}
});
return new Response(stream, {
status: response.status,
headers: responseHeaders
});
} catch (error) {
console.error('Proxy video error:', error);
// 确保在错误情况下也释放资源
if (reader && typeof (reader as any)?.releaseLock === 'function') {
try {
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
} catch (e) {
// 忽略错误
}
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略错误
}
}
return NextResponse.json({
error: 'Failed to proxy video',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}
// 支持OPTIONS请求用于CORS预检
export async function OPTIONS(_request: Request) {
console.log('CORS preflight request received');
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS, POST',
'Access-Control-Allow-Headers': 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges, Content-Type',
'Access-Control-Allow-Credentials': 'false',
'Access-Control-Max-Age': '86400',
},
});
}

View File

@ -0,0 +1,128 @@
// /* eslint-disable no-console */
// import { NextResponse } from "next/server";
// export const runtime = 'nodejs';
// // 测试视频URL的可达性用于调试403问题
// export async function GET(request: Request) {
// const { searchParams } = new URL(request.url);
// const url = searchParams.get('url');
// if (!url) {
// return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
// }
// try {
// const decodedUrl = decodeURIComponent(url);
// console.log('Testing video URL:', decodedUrl);
// // 测试不同的请求头配置
// const testConfigs = [
// // 配置1基础浏览器头
// {
// name: 'Basic Browser Headers',
// 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',
// 'Accept': '*/*',
// }
// },
// // 配置2夸克网盘专用头
// {
// name: 'Quark Drive Headers',
// 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',
// 'Accept': '*/*',
// 'Referer': 'https://pan.quark.cn/',
// 'Origin': 'https://pan.quark.cn',
// }
// },
// // 配置3简化头
// {
// name: 'Minimal Headers',
// headers: {
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
// }
// }
// ];
// const results = [];
// for (const config of testConfigs) {
// try {
// console.log(`Testing with ${config.name}...`);
// // 只发送HEAD请求来检查URL可达性避免下载大文件
// const response = await fetch(decodedUrl, {
// method: 'HEAD',
// headers: config.headers,
// signal: AbortSignal.timeout(10000), // 10秒超时
// });
// results.push({
// config: config.name,
// status: response.status,
// statusText: response.statusText,
// success: response.ok,
// headers: Object.fromEntries(response.headers.entries()),
// contentType: response.headers.get('content-type'),
// contentLength: response.headers.get('content-length'),
// acceptRanges: response.headers.get('accept-ranges'),
// });
// console.log(`${config.name}: ${response.status} ${response.statusText}`);
// // 如果成功,不需要测试其他配置
// if (response.ok) {
// break;
// }
// } catch (error) {
// results.push({
// config: config.name,
// error: error instanceof Error ? error.message : String(error),
// success: false,
// });
// console.log(`${config.name}: Error - ${error}`);
// }
// }
// return NextResponse.json({
// url: decodedUrl,
// testResults: results,
// timestamp: new Date().toISOString(),
// }, {
// headers: {
// 'Access-Control-Allow-Origin': '*',
// 'Access-Control-Allow-Methods': 'GET, OPTIONS',
// 'Access-Control-Allow-Headers': 'Content-Type',
// }
// });
// } catch (error) {
// console.error('Test URL error:', error);
// return NextResponse.json({
// error: 'Failed to test URL',
// details: error instanceof Error ? error.message : String(error)
// }, {
// status: 500,
// headers: {
// 'Access-Control-Allow-Origin': '*',
// 'Access-Control-Allow-Methods': 'GET, OPTIONS',
// 'Access-Control-Allow-Headers': 'Content-Type',
// }
// });
// }
// }
// export async function OPTIONS() {
// return new Response(null, {
// status: 200,
// headers: {
// 'Access-Control-Allow-Origin': '*',
// 'Access-Control-Allow-Methods': 'GET, OPTIONS',
// 'Access-Control-Allow-Headers': 'Content-Type',
// 'Access-Control-Max-Age': '86400',
// },
// });
// }

View File

@ -0,0 +1,92 @@
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';
export const runtime = 'nodejs';
// OrionTV 兼容接口
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 query = searchParams.get('q');
const resourceId = searchParams.get('resourceId');
if (!query || !resourceId) {
const cacheTime = await getCacheTime();
return NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' },
{
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',
},
}
);
}
const config = await getConfig();
const apiSites = await getAvailableApiSites(authInfo.username);
try {
// 根据 resourceId 查找对应的 API 站点
const targetSite = apiSites.find((site) => site.key === resourceId);
if (!targetSite) {
return NextResponse.json(
{
error: `未找到指定的视频源: ${resourceId}`,
result: null,
},
{ status: 404 }
);
}
const results = await searchFromApi(targetSite, query);
let result = results.filter((r) => r.title === query);
if (!config.SiteConfig.DisableYellowFilter) {
result = result.filter((result) => {
const typeName = result.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 }
);
} else {
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',
},
}
);
}
} catch (error) {
return NextResponse.json(
{
error: '搜索失败',
result: null,
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,19 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAvailableApiSites } from '@/lib/config';
export const runtime = 'nodejs';
// OrionTV 兼容接口
export async function GET(request: NextRequest) {
console.log('request', request.url);
try {
const apiSites = await getAvailableApiSites();
return NextResponse.json(apiSites);
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
}
}

149
src/app/api/search/route.ts Normal file
View File

@ -0,0 +1,149 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
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';
// 短剧搜索函数
async function searchShortDrama(query: string, page = 1, limit = 20): Promise<any[]> {
try {
// 使用 AbortController 实现超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(`https://api.r2afosne.dpdns.org/vod/search?name=${encodeURIComponent(query)}&page=${page}&limit=${limit}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'LunaTV/1.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Short drama API returned ${response.status}`);
}
const data = await response.json();
if (!data.list || !Array.isArray(data.list)) {
return [];
}
// 将短剧数据转换为统一的搜索结果格式,限制数量避免卡顿
const limitedResults = data.list.slice(0, limit);
return limitedResults.map((item: any) => ({
id: item.id?.toString() || '',
title: item.name || '',
poster: item.cover || '',
year: item.update_time ? new Date(item.update_time).getFullYear().toString() : 'unknown',
episodes: [{ id: '1', name: '第1集' }], // 短剧通常有多集,但这里简化处理
source: 'shortdrama',
source_name: '短剧',
douban_id: 0,
type_name: '短剧',
// 短剧特有字段
score: item.score || 0,
update_time: item.update_time || '',
vod_class: '',
vod_tag: '',
}));
} catch (error) {
console.warn('短剧搜索失败:', error);
return [];
}
}
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 query = searchParams.get('q');
if (!query) {
const cacheTime = await getCacheTime();
return NextResponse.json(
{ results: [] },
{
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',
},
}
);
}
const config = await getConfig();
const apiSites = await getAvailableApiSites(authInfo.username);
// 添加超时控制和错误处理,避免慢接口拖累整体响应
const searchPromises = apiSites.map((site) =>
Promise.race([
searchFromApi(site, query),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
),
]).then((results: unknown) => {
// 限制每个源的结果数量,避免页面卡顿
return Array.isArray(results) ? results.slice(0, 50) : [];
}).catch((err) => {
console.warn(`搜索失败 ${site.name}:`, err.message);
return []; // 返回空数组而不是抛出错误
})
);
// 添加短剧搜索
const shortDramaSearchPromise = searchShortDrama(query, 1, 20).catch((err) => {
console.warn('短剧搜索失败:', err.message);
return [];
});
// 将短剧搜索添加到所有搜索Promise中
searchPromises.push(shortDramaSearchPromise);
try {
const results = await Promise.allSettled(searchPromises);
const successResults = results
.filter((result) => result.status === 'fulfilled')
.map((result) => (result as PromiseFulfilledResult<any>).value);
let flattenedResults = successResults.flat();
if (!config.SiteConfig.DisableYellowFilter) {
flattenedResults = flattenedResults.filter((result) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
const cacheTime = await getCacheTime();
if (flattenedResults.length === 0) {
// no cache if empty
return NextResponse.json({ results: [] }, { status: 200 });
}
return NextResponse.json(
{ results: flattenedResults },
{
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',
},
}
);
} catch (error) {
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { AdminConfig } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
const { searchParams } = new URL(request.url);
const query = searchParams.get('q')?.trim();
if (!query) {
return NextResponse.json({ suggestions: [] });
}
// 生成建议
const suggestions = await generateSuggestions(config, query, authInfo.username);
// 从配置中获取缓存时间如果没有配置则使用默认值300秒5分钟
const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
return NextResponse.json(
{ suggestions },
{
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',
},
}
);
} catch (error) {
console.error('获取搜索建议失败', error);
return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 });
}
}
async function generateSuggestions(config: AdminConfig, query: string, username: string): Promise<
Array<{
text: string;
type: 'exact' | 'related' | 'suggestion';
score: number;
}>
> {
const queryLower = query.toLowerCase();
const apiSites = await getAvailableApiSites(username);
let realKeywords: string[] = [];
if (apiSites.length > 0) {
// 取第一个可用的数据源进行搜索
const firstSite = apiSites[0];
const results = await searchFromApi(firstSite, query);
realKeywords = Array.from(
new Set(
results
.filter((r: any) => config.SiteConfig.DisableYellowFilter || !yellowWords.some((word: string) => (r.type_name || '').includes(word)))
.map((r: any) => r.title)
.filter(Boolean)
.flatMap((title: string) => title.split(/[ -::·、-]/))
.filter(
(w: string) => w.length > 1 && w.toLowerCase().includes(queryLower)
)
)
).slice(0, 8);
}
// 根据关键词与查询的匹配程度计算分数,并动态确定类型
const realSuggestions = realKeywords.map((word) => {
const wordLower = word.toLowerCase();
const queryWords = queryLower.split(/[ -::·、-]/);
// 计算匹配分数:完全匹配得分更高
let score = 1.0;
if (wordLower === queryLower) {
score = 2.0; // 完全匹配
} else if (
wordLower.startsWith(queryLower) ||
wordLower.endsWith(queryLower)
) {
score = 1.8; // 前缀或后缀匹配
} else if (queryWords.some((qw) => wordLower.includes(qw))) {
score = 1.5; // 包含查询词
}
// 根据匹配程度确定类型
let type: 'exact' | 'related' | 'suggestion' = 'related';
if (score >= 2.0) {
type = 'exact';
} else if (score >= 1.5) {
type = 'related';
} else {
type = 'suggestion';
}
return {
text: word,
type,
score,
};
});
// 按分数降序排列,相同分数按类型优先级排列
const sortedSuggestions = realSuggestions.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score; // 分数高的在前
}
// 分数相同时按类型优先级exact > related > suggestion
const typePriority = { exact: 3, related: 2, suggestion: 1 };
return typePriority[b.type] - typePriority[a.type];
});
return sortedSuggestions;
}

View File

@ -0,0 +1,317 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
// 短剧搜索函数
async function searchShortDrama(query: string, page = 1, limit = 20): Promise<any[]> {
try {
// 使用 AbortController 实现超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(`https://api.r2afosne.dpdns.org/vod/search?name=${encodeURIComponent(query)}&page=${page}&limit=${limit}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'LunaTV/1.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Short drama API returned ${response.status}`);
}
const data = await response.json();
if (!data.list || !Array.isArray(data.list)) {
return [];
}
// 将短剧数据转换为统一的搜索结果格式,限制数量避免卡顿
const limitedResults = data.list.slice(0, limit);
return limitedResults.map((item: any) => ({
id: item.id?.toString() || '',
title: item.name || '',
poster: item.cover || '',
year: item.update_time ? new Date(item.update_time).getFullYear().toString() : 'unknown',
episodes: [{ id: '1', name: '第1集' }], // 短剧通常有多集,但这里简化处理
source: 'shortdrama',
source_name: '短剧',
douban_id: 0,
type_name: '短剧',
// 短剧特有字段
score: item.score || 0,
update_time: item.update_time || '',
vod_class: '',
vod_tag: '',
}));
} catch (error) {
console.warn('短剧搜索失败:', error);
return [];
}
}
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 query = searchParams.get('q');
if (!query) {
return new Response(
JSON.stringify({ error: '搜索关键词不能为空' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
const config = await getConfig();
const apiSites = await getAvailableApiSites(authInfo.username);
// 共享状态
let streamClosed = false;
// 创建可读流
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// 辅助函数:安全地向控制器写入数据
const safeEnqueue = (data: Uint8Array) => {
try {
if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
// 流已标记为关闭或控制器已关闭
return false;
}
controller.enqueue(data);
return true;
} catch (error) {
// 控制器已关闭或出现其他错误
console.warn('Failed to enqueue data:', error);
streamClosed = true;
return false;
}
};
// 发送开始事件 (包含短剧搜索源)
const startEvent = `data: ${JSON.stringify({
type: 'start',
query,
totalSources: apiSites.length + 1, // +1 for short drama search
timestamp: Date.now()
})}\n\n`;
if (!safeEnqueue(encoder.encode(startEvent))) {
return; // 连接已关闭,提前退出
}
// 记录已完成的源数量
let completedSources = 0;
const allResults: any[] = [];
// 为每个源创建搜索 Promise
const searchPromises = [...apiSites.map(async (site) => {
try {
// 添加超时控制
const searchPromise = Promise.race([
searchFromApi(site, query),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
),
]);
const results = await searchPromise as any[];
// 过滤黄色内容并限制结果数量
let filteredResults = results;
if (!config.SiteConfig.DisableYellowFilter) {
filteredResults = results.filter((result) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
// 限制每个源的结果数量,避免页面卡顿
const limitedResults = filteredResults.slice(0, 50);
// 发送该源的搜索结果
completedSources++;
if (!streamClosed) {
const sourceEvent = `data: ${JSON.stringify({
type: 'source_result',
source: site.key,
sourceName: site.name,
results: limitedResults,
timestamp: Date.now()
})}\n\n`;
if (!safeEnqueue(encoder.encode(sourceEvent))) {
streamClosed = true;
return; // 连接已关闭,停止处理
}
}
if (limitedResults.length > 0) {
allResults.push(...limitedResults);
}
} catch (error) {
console.warn(`搜索失败 ${site.name}:`, error);
// 发送源错误事件
completedSources++;
if (!streamClosed) {
const errorEvent = `data: ${JSON.stringify({
type: 'source_error',
source: site.key,
sourceName: site.name,
error: error instanceof Error ? error.message : '搜索失败',
timestamp: Date.now()
})}\n\n`;
if (!safeEnqueue(encoder.encode(errorEvent))) {
streamClosed = true;
return; // 连接已关闭,停止处理
}
}
}
// 检查是否所有源都已完成 (包括短剧搜索)
if (completedSources === apiSites.length + 1) {
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);
}
}
}
}
}),
// 短剧搜索Promise
(async () => {
try {
const results = await searchShortDrama(query, 1, 20);
// 发送短剧搜索结果
completedSources++;
if (!streamClosed && results.length > 0) {
const sourceEvent = `data: ${JSON.stringify({
type: 'source_result',
source: 'shortdrama',
sourceName: '短剧',
results: results,
timestamp: Date.now()
})}\n\n`;
if (!safeEnqueue(encoder.encode(sourceEvent))) {
streamClosed = true;
return;
}
allResults.push(...results);
} else if (!streamClosed) {
// 即使没有结果,也要发送完成事件
const sourceEvent = `data: ${JSON.stringify({
type: 'source_result',
source: 'shortdrama',
sourceName: '短剧',
results: [],
timestamp: Date.now()
})}\n\n`;
safeEnqueue(encoder.encode(sourceEvent));
}
} catch (error) {
console.warn('短剧搜索失败:', error);
completedSources++;
if (!streamClosed) {
const errorEvent = `data: ${JSON.stringify({
type: 'source_error',
source: 'shortdrama',
sourceName: '短剧',
error: error instanceof Error ? error.message : '搜索失败',
timestamp: Date.now()
})}\n\n`;
safeEnqueue(encoder.encode(errorEvent));
}
}
// 检查是否所有源都已完成
if (completedSources === apiSites.length + 1) {
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);
},
cancel() {
// 客户端断开连接时,标记流已关闭
streamClosed = true;
console.log('Client disconnected, cancelling search stream');
},
});
// 返回流式响应
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@ -0,0 +1,142 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 最大保存条数(与客户端保持一致)
const HISTORY_LIMIT = 20;
/**
* GET /api/searchhistory
* string[]
*/
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const history = await db.getSearchHistory(authInfo.username);
return NextResponse.json(history, { status: 200 });
} catch (err) {
console.error('获取搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/searchhistory
* body: { keyword: string }
*/
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const keyword: string = body.keyword?.trim();
if (!keyword) {
return NextResponse.json(
{ error: 'Keyword is required' },
{ status: 400 }
);
}
await db.addSearchHistory(authInfo.username, keyword);
// 再次获取最新列表,确保客户端与服务端同步
const history = await db.getSearchHistory(authInfo.username);
return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 });
} catch (err) {
console.error('添加搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* DELETE /api/searchhistory?keyword=<kw>
*
* 1. keyword ->
* 2. keyword=<kw> ->
*/
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const kw = searchParams.get('keyword')?.trim();
await db.deleteSearchHistory(authInfo.username, kw || undefined);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,20 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { CURRENT_VERSION } from '@/lib/version'
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
console.log('server-config called: ', request.url);
const config = await getConfig();
const result = {
SiteName: config.SiteConfig.SiteName,
StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
Version: CURRENT_VERSION,
};
return NextResponse.json(result);
}

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
export async function GET(_request: NextRequest) {
try {
// 先尝试调用外部API
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
const response = await fetch(`${API_CONFIG.shortdrama.baseUrl}/vod/categories`, {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
return NextResponse.json(data);
} else {
throw new Error(`External API failed: ${response.status}`);
}
} catch (error) {
console.error('Short drama categories API error:', error);
// 如果外部API失败返回默认分类数据作为备用
return NextResponse.json({
categories: [
{ type_id: 1, type_name: '古装' },
{ type_id: 2, type_name: '现代' },
{ type_id: 3, type_name: '都市' },
{ type_id: 4, type_name: '言情' },
{ type_id: 5, type_name: '悬疑' },
{ type_id: 6, type_name: '喜剧' },
{ type_id: 7, type_name: '其他' },
],
total: 7,
});
}
}

View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 最新剧集API使用vod_id作为实际视频ID
function transformExternalData(externalItem: any) {
return {
id: externalItem.id ? externalItem.id.toString() : (externalItem.vod_id?.toString() || ''), // 保持原有id作为唯一标识
vod_id: externalItem.vod_id, // 使用vod_id作为实际的视频ID用于获取全集地址
name: externalItem.vod_name || '未知短剧', // 最新剧集API返回的是vod_name
cover: externalItem.vod_pic || 'https://via.placeholder.com/300x400', // 最新剧集API返回的是vod_pic
update_time: externalItem.vod_time || new Date().toISOString(), // 最新剧集API返回的是vod_time
score: externalItem.vod_score || externalItem.vod_douban_score || 0, // 最新剧集API返回的是vod_score
total_episodes: externalItem.vod_total || '1', // 最新剧集API返回的是vod_total
vod_class: externalItem.vod_class || '', // 添加分类字段映射
vod_tag: externalItem.vod_tag || '', // 添加标签字段映射
};
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/latest`);
apiUrl.searchParams.append('page', page);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const externalData = await response.json();
// 处理外部API响应格式
if (externalData && externalData.list && Array.isArray(externalData.list)) {
const transformedData = externalData.list.map(transformExternalData);
return NextResponse.json(transformedData);
} else {
throw new Error('Invalid response format from external API');
}
} catch (error) {
console.error('Short drama latest API error:', error);
// 返回默认最新数据作为备用格式与真实API一致
const mockData = Array.from({ length: 25 }, (_, index) => {
const classOptions = ['都市情感', '古装宫廷', '现代言情', '豪门世家', '职场励志'];
const tagOptions = [
'甜宠,霸总,现代',
'穿越,古装,宫斗',
'复仇,虐渣,打脸',
'重生,逆袭,强者归来',
'家庭,伦理,现实'
];
return {
id: `mock_id_${index + 100}`, // 模拟字符串ID
vod_id: index + 100, // 模拟数字vod_id用于获取全集地址
vod_name: `最新短剧 ${index + 1}`,
vod_pic: 'https://via.placeholder.com/300x400',
vod_time: new Date(Date.now() - index * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').substring(0, 19),
vod_score: Math.floor(Math.random() * 4) + 7, // 7-10的随机分数
vod_total: `${Math.floor(Math.random() * 30) + 10}`, // 模拟总集数
vod_class: classOptions[index % classOptions.length], // 模拟分类
vod_tag: tagOptions[index % tagOptions.length], // 模拟标签
};
});
// 确保返回数组
return NextResponse.json(mockData);
}
}

View File

@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 分类热搜API直接使用id作为视频ID
function transformExternalData(externalItem: any) {
return {
id: externalItem.id ? externalItem.id.toString() : '', // 分类热搜API返回的id就是唯一标识
vod_id: externalItem.id, // 分类热搜API返回的id就是视频ID用于获取全集地址
name: externalItem.name || '未知短剧', // 分类热搜API返回的是name
cover: externalItem.cover || 'https://via.placeholder.com/300x400', // 分类热搜API返回的是cover
update_time: externalItem.update_time || new Date().toISOString(), // 分类热搜API返回的是update_time
score: externalItem.score || 0, // 分类热搜API返回的是score
total_episodes: '1', // 分类热搜API通常不返回总集数设为默认值
vod_class: externalItem.vod_class || '', // 添加分类字段映射
vod_tag: externalItem.vod_tag || '', // 添加标签字段映射
};
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const categoryId = searchParams.get('categoryId');
const page = searchParams.get('page') || '1';
if (!categoryId) {
return NextResponse.json(
{ error: 'categoryId is required' },
{ status: 400 }
);
}
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/list`);
apiUrl.searchParams.append('categoryId', categoryId);
apiUrl.searchParams.append('page', page);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const externalData = await response.json();
// 处理外部API响应格式
if (externalData && externalData.list && Array.isArray(externalData.list)) {
const transformedList = externalData.list.map(transformExternalData);
return NextResponse.json({
total: externalData.total || 0,
totalPages: externalData.totalPages || externalData.pagecount || 1,
currentPage: externalData.currentPage || externalData.page || 1,
list: transformedList,
});
} else {
throw new Error('Invalid response format from external API');
}
} catch (error) {
console.error('Short drama list API error:', error);
// 返回默认列表数据作为备用格式与真实分类热搜API一致
const mockData = Array.from({ length: 25 }, (_, index) => {
const classOptions = ['都市情感', '古装宫廷', '现代言情', '豪门世家', '职场励志'];
const tagOptions = [
'甜宠,霸总,现代',
'穿越,古装,宫斗',
'复仇,虐渣,打脸',
'重生,逆袭,强者归来',
'家庭,伦理,现实'
];
return {
id: index + 1000, // 直接使用数字ID与分类热搜API一致这个ID就是视频ID
name: `短剧示例 ${index + 1}`,
cover: 'https://via.placeholder.com/300x400',
update_time: new Date().toISOString().replace('T', ' ').substring(0, 19),
score: Math.floor(Math.random() * 5) + 6, // 6-10的随机分数
vod_class: classOptions[index % classOptions.length], // 模拟分类
vod_tag: tagOptions[index % tagOptions.length], // 模拟标签
};
});
return NextResponse.json({
total: 100,
totalPages: 4,
currentPage: parseInt(request.nextUrl.searchParams.get('page') || '1'),
list: mockData,
});
}
}

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'id parameter is required' },
{ status: 400 }
);
}
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/all`);
apiUrl.searchParams.append('id', id);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒超时为获取全集地址提供充足时间
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
// 直接返回API响应数据播放页面会处理数据结构转换
return NextResponse.json(data);
} catch (error) {
console.error('Short drama all parse API error:', error);
// 返回模拟的短剧数据作为备用
const { searchParams: errorSearchParams } = new URL(request.url);
const errorId = errorSearchParams.get('id');
const mockData = {
videoId: parseInt(errorId || '1') || 1,
videoName: '短剧播放示例',
results: Array.from({ length: 10 }, (_, index) => ({
index: index,
label: `${index + 1}`,
parsedUrl: `https://example.com/video${index + 1}.mp4`,
parseInfo: {
headers: {},
type: 'mp4'
},
status: 'success',
reason: null
})),
totalEpisodes: 10,
successfulCount: 10,
failedCount: 0,
cover: 'https://via.placeholder.com/300x400',
description: '这是一个示例短剧,用于测试播放功能。'
};
return NextResponse.json(mockData);
}
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const episodes = searchParams.get('episodes');
if (!id) {
return NextResponse.json(
{ error: 'id parameter is required' },
{ status: 400 }
);
}
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/batch`);
apiUrl.searchParams.append('id', id);
if (episodes) apiUrl.searchParams.append('episodes', episodes);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Short drama batch parse API error:', error);
// 返回默认数据作为备用
return NextResponse.json({
code: 500,
message: 'Failed to parse episodes',
data: null,
});
}
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const episode = searchParams.get('episode');
if (!id) {
return NextResponse.json(
{ error: 'id parameter is required' },
{ status: 400 }
);
}
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/single`);
apiUrl.searchParams.append('id', id);
if (episode) apiUrl.searchParams.append('episode', episode);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Short drama single parse API error:', error);
// 返回默认数据作为备用
return NextResponse.json({
code: 500,
message: 'Failed to parse episode',
data: null,
});
}
}

View File

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 推荐API通常和分类热搜格式相同
function transformExternalData(externalItem: any) {
return {
id: externalItem.vod_id ? externalItem.vod_id.toString() : (externalItem.id?.toString() || ''),
vod_id: externalItem.vod_id, // 推荐API返回的是vod_id
name: externalItem.vod_name || '未知短剧', // 推荐API返回的是vod_name
cover: externalItem.vod_pic || 'https://via.placeholder.com/300x400', // 推荐API返回的是vod_pic
update_time: externalItem.vod_time || new Date().toISOString(), // 推荐API可能不返回时间使用当前时间
score: externalItem.vod_score || 0, // 推荐API返回的是vod_score
total_episodes: externalItem.vod_remarks?.replace(/[^0-9]/g, '') || '1', // 从vod_remarks提取集数
vod_class: externalItem.vod_class || '', // 添加分类字段映射
vod_tag: externalItem.vod_tag || '', // 添加标签字段映射
};
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const categoryId = searchParams.get('categoryId');
const size = searchParams.get('size') || '25';
try {
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/recommend`);
if (categoryId) apiUrl.searchParams.append('categoryId', categoryId);
apiUrl.searchParams.append('size', size);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const externalData = await response.json();
// 处理外部API响应格式 - 推荐API返回的是带有items数组的对象
if (externalData && externalData.items && Array.isArray(externalData.items)) {
const transformedItems = externalData.items.map(transformExternalData);
const recommendResponse = {
mode: externalData.mode || 'random',
categoryId: externalData.categoryId || 0,
categoryName: externalData.categoryName || null,
total: externalData.total || transformedItems.length,
items: transformedItems,
};
return NextResponse.json(recommendResponse);
} else {
throw new Error('Invalid response format from external API');
}
} catch (error) {
console.error('Short drama recommend API error:', error);
// 返回默认推荐数据作为备用格式与真实推荐API一致
const mockItems = Array.from({ length: 5 }, (_, index) => ({
id: (index + 500).toString(),
vod_id: index + 500,
name: `推荐短剧 ${index + 1}`,
cover: 'https://via.placeholder.com/300x400',
update_time: new Date().toISOString().replace('T', ' ').substring(0, 19),
score: Math.floor(Math.random() * 3) + 8, // 8-10的随机分数
total_episodes: `${Math.floor(Math.random() * 50) + 10}`,
vod_class: '都市情感',
vod_tag: '甜宠,霸总,现代',
}));
const mockResponse = {
mode: 'random',
categoryId: parseInt(categoryId || '0'),
categoryName: null,
total: 5,
items: mockItems,
};
return NextResponse.json(mockResponse);
}
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name');
try {
if (!name) {
return NextResponse.json(
{ error: 'name parameter is required' },
{ status: 400 }
);
}
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/search`);
apiUrl.searchParams.append('name', name);
const response = await fetch(apiUrl.toString(), {
method: 'GET',
headers: API_CONFIG.shortdrama.headers,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Short drama search API error:', error);
// 返回默认搜索结果作为备用
const mockData = Array.from({ length: 5 }, (_, index) => ({
id: index + 200,
name: `搜索结果: ${name} ${index + 1}`,
cover: 'https://via.placeholder.com/300x400',
update_time: new Date().toISOString(),
score: Math.floor(Math.random() * 3) + 8, // 8-10的随机分数
}));
return NextResponse.json({
total: mockData.length,
totalPages: 1,
currentPage: 1,
list: mockData,
});
}
}

View File

@ -0,0 +1,152 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { SkipConfig } from '@/lib/types';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const config = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const source = searchParams.get('source');
const id = searchParams.get('id');
if (source && id) {
// 获取单个配置
const config = await db.getSkipConfig(authInfo.username, source, id);
return NextResponse.json(config);
} else {
// 获取所有配置
const configs = await db.getAllSkipConfigs(authInfo.username);
return NextResponse.json(configs);
}
} catch (error) {
console.error('获取跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '获取跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const adminConfig = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, config } = body;
if (!key || !config) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
// 验证配置格式
const skipConfig: SkipConfig = {
enable: Boolean(config.enable),
intro_time: Number(config.intro_time) || 0,
outro_time: Number(config.outro_time) || 0,
};
await db.setSkipConfig(authInfo.username, source, id, skipConfig);
return NextResponse.json({ success: true });
} catch (error) {
console.error('保存跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '保存跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const adminConfig = await getConfig();
if (authInfo.username !== process.env.ADMIN_USERNAME) {
// 非站长,检查用户存在或被封禁
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
await db.deleteSkipConfig(authInfo.username, source, id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('删除跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '删除跳过片头片尾配置失败' },
{ status: 500 }
);
}
}

822
src/app/douban/page.tsx Normal file
View File

@ -0,0 +1,822 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { GetBangumiCalendarData } from '@/lib/bangumi.client';
import {
getDoubanCategories,
getDoubanList,
getDoubanRecommends,
} from '@/lib/douban.client';
import { DoubanItem, DoubanResult } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import DoubanCustomSelector from '@/components/DoubanCustomSelector';
import DoubanSelector from '@/components/DoubanSelector';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
function DoubanPageClient() {
const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 用于存储最新参数值的 refs
const currentParamsRef = useRef({
type: '',
primarySelection: '',
secondarySelection: '',
multiLevelSelection: {} as Record<string, string>,
selectedWeekday: '',
currentPage: 0,
});
const type = searchParams.get('type') || 'movie';
// 获取 runtimeConfig 中的自定义分类数据
const [customCategories, setCustomCategories] = useState<
Array<{ name: string; type: 'movie' | 'tv'; query: string }>
>([]);
// 选择器状态 - 完全独立不依赖URL参数
const [primarySelection, setPrimarySelection] = useState<string>(() => {
if (type === 'movie') return '热门';
if (type === 'tv' || type === 'show') return '最近热门';
if (type === 'anime') return '每日放送';
return '';
});
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
if (type === 'movie') return '全部';
if (type === 'tv') return 'tv';
if (type === 'show') return 'show';
return '全部';
});
// MultiLevelSelector 状态
const [multiLevelValues, setMultiLevelValues] = useState<
Record<string, string>
>({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 星期选择器状态
const [selectedWeekday, setSelectedWeekday] = useState<string>('');
// 获取自定义分类数据
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
}
}, []);
// 同步最新参数值到 ref
useEffect(() => {
currentParamsRef.current = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage,
};
}, [
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
currentPage,
]);
// 初始化时标记选择器为准备好状态
useEffect(() => {
// 短暂延迟确保初始状态设置完成
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// type变化时立即重置selectorsReady最高优先级
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
}, [type]);
// 当type变化时重置选择器状态
useEffect(() => {
if (type === 'custom' && customCategories.length > 0) {
// 自定义分类模式:优先选择 movie如果没有 movie 则选择 tv
const types = Array.from(
new Set(customCategories.map((cat) => cat.type))
);
if (types.length > 0) {
// 优先选择 movie如果没有 movie 则选择 tv
let selectedType = types[0]; // 默认选择第一个
if (types.includes('movie')) {
selectedType = 'movie';
} else {
selectedType = 'tv';
}
setPrimarySelection(selectedType);
// 设置选中类型的第一个分类的 query 作为二级选择
const firstCategory = customCategories.find(
(cat) => cat.type === selectedType
);
if (firstCategory) {
setSecondarySelection(firstCategory.query);
}
}
} else {
// 原有逻辑
if (type === 'movie') {
setPrimarySelection('热门');
setSecondarySelection('全部');
} else if (type === 'tv') {
setPrimarySelection('最近热门');
setSecondarySelection('tv');
} else if (type === 'show') {
setPrimarySelection('最近热门');
setSecondarySelection('show');
} else if (type === 'anime') {
setPrimarySelection('每日放送');
setSecondarySelection('全部');
} else {
setPrimarySelection('');
setSecondarySelection('全部');
}
}
// 清空 MultiLevelSelector 状态
setMultiLevelValues({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 使用短暂延迟确保状态更新完成后标记选择器准备好
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, [type, customCategories]);
// 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
// 参数快照比较函数
const isSnapshotEqual = useCallback(
(
snapshot1: {
type: string;
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record<string, string>;
selectedWeekday: string;
currentPage: number;
},
snapshot2: {
type: string;
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record<string, string>;
selectedWeekday: string;
currentPage: number;
}
) => {
return (
snapshot1.type === snapshot2.type &&
snapshot1.primarySelection === snapshot2.primarySelection &&
snapshot1.secondarySelection === snapshot2.secondarySelection &&
snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
snapshot1.currentPage === snapshot2.currentPage &&
JSON.stringify(snapshot1.multiLevelSelection) ===
JSON.stringify(snapshot2.multiLevelSelection)
);
},
[]
);
// 生成API请求参数的辅助函数
const getRequestParams = useCallback(
(pageStart: number) => {
// 当type为tv或show时kind统一为'tv'category使用type本身
if (type === 'tv' || type === 'show') {
return {
kind: 'tv' as const,
category: type,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
}
// 电影类型保持原逻辑
return {
kind: type as 'tv' | 'movie',
category: primarySelection,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
},
[type, primarySelection, secondarySelection]
);
// 防抖的数据加载函数
const loadInitialData = useCallback(async () => {
// 创建当前参数的快照
const requestSnapshot = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage: 0,
};
try {
setLoading(true);
// 确保在加载初始数据时重置页面状态
setDoubanData([]);
setCurrentPage(0);
setHasMore(true);
setIsLoadingMore(false);
let data: DoubanResult;
if (type === 'custom') {
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
const selectedCategory = customCategories.find(
(cat) =>
cat.type === primarySelection && cat.query === secondarySelection
);
if (selectedCategory) {
data = await getDoubanList({
tag: selectedCategory.query,
type: selectedCategory.type,
pageLimit: 25,
pageStart: 0,
});
} else {
throw new Error('没有找到对应的分类');
}
} else if (type === 'anime' && primarySelection === '每日放送') {
const calendarData = await GetBangumiCalendarData();
const weekdayData = calendarData.find(
(item) => item.weekday.en === selectedWeekday
);
if (weekdayData) {
data = {
code: 200,
message: 'success',
list: weekdayData.items.map((item) => ({
id: item.id?.toString() || '',
title: item.name_cn || item.name,
poster:
item.images.large ||
item.images.common ||
item.images.medium ||
item.images.small ||
item.images.grid,
rate: item.rating?.score?.toFixed(1) || '',
year: item.air_date?.split('-')?.[0] || '',
})),
};
} else {
throw new Error('没有找到对应的日期');
}
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
pageLimit: 25,
pageStart: 0,
category: '动画',
format: primarySelection === '番剧' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else if (primarySelection === '全部') {
data = await getDoubanRecommends({
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
pageLimit: 25,
pageStart: 0, // 初始数据加载始终从第一页开始
category: multiLevelValues.type
? (multiLevelValues.type as string)
: '',
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else {
data = await getDoubanCategories(getRequestParams(0));
}
if (data.code === 200) {
// 检查参数是否仍然一致,如果一致才设置数据
// 使用 ref 获取最新的当前值
const currentSnapshot = { ...currentParamsRef.current };
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
setDoubanData(data.list);
setHasMore(data.list.length !== 0);
setLoading(false);
} else {
console.log('参数不一致,不执行任何操作,避免设置过期数据');
}
// 如果参数不一致,不执行任何操作,避免设置过期数据
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
setLoading(false); // 发生错误时总是停止loading状态
}
}, [
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
getRequestParams,
customCategories,
]);
// 只在选择器准备好后才加载数据
useEffect(() => {
// 只有在选择器准备好时才开始加载
if (!selectorsReady) {
return;
}
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
debounceTimeoutRef.current = setTimeout(() => {
loadInitialData();
}, 100); // 100ms 防抖延迟
// 清理函数
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [
selectorsReady,
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
loadInitialData,
]);
// 单独处理 currentPage 变化(加载更多)
useEffect(() => {
if (currentPage > 0) {
const fetchMoreData = async () => {
// 创建当前参数的快照
const requestSnapshot = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage,
};
try {
setIsLoadingMore(true);
let data: DoubanResult;
if (type === 'custom') {
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
const selectedCategory = customCategories.find(
(cat) =>
cat.type === primarySelection &&
cat.query === secondarySelection
);
if (selectedCategory) {
data = await getDoubanList({
tag: selectedCategory.query,
type: selectedCategory.type,
pageLimit: 25,
pageStart: currentPage * 25,
});
} else {
throw new Error('没有找到对应的分类');
}
} else if (type === 'anime' && primarySelection === '每日放送') {
// 每日放送模式下,不进行数据请求,返回空数据
data = {
code: 200,
message: 'success',
list: [],
};
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
pageLimit: 25,
pageStart: currentPage * 25,
category: '动画',
format: primarySelection === '番剧' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year
? (multiLevelValues.year as string)
: '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort
? (multiLevelValues.sort as string)
: '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else if (primarySelection === '全部') {
data = await getDoubanRecommends({
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
pageLimit: 25,
pageStart: currentPage * 25,
category: multiLevelValues.type
? (multiLevelValues.type as string)
: '',
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year
? (multiLevelValues.year as string)
: '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort
? (multiLevelValues.sort as string)
: '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else {
data = await getDoubanCategories(
getRequestParams(currentPage * 25)
);
}
if (data.code === 200) {
// 检查参数是否仍然一致,如果一致才设置数据
// 使用 ref 获取最新的当前值
const currentSnapshot = { ...currentParamsRef.current };
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
setDoubanData((prev) => [...prev, ...data.list]);
setHasMore(data.list.length !== 0);
} else {
console.log('参数不一致,不执行任何操作,避免设置过期数据');
}
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
} finally {
setIsLoadingMore(false);
}
};
fetchMoreData();
}
}, [
currentPage,
type,
primarySelection,
secondarySelection,
customCategories,
multiLevelValues,
selectedWeekday,
]);
// 设置滚动监听
useEffect(() => {
// 如果没有更多数据或正在加载,则不设置监听
if (!hasMore || isLoadingMore || loading) {
return;
}
// 确保 loadingRef 存在
if (!loadingRef.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
setCurrentPage((prev) => prev + 1);
}
},
{ threshold: 0.1 }
);
observer.observe(loadingRef.current);
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, isLoadingMore, loading]);
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
// 只有当值真正改变时才设置loading状态
if (value !== primarySelection) {
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
// 清空 MultiLevelSelector 状态
setMultiLevelValues({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 如果是自定义分类模式,同时更新一级和二级选择器
if (type === 'custom' && customCategories.length > 0) {
const firstCategory = customCategories.find(
(cat) => cat.type === value
);
if (firstCategory) {
// 批量更新状态,避免多次触发数据加载
setPrimarySelection(value);
setSecondarySelection(firstCategory.query);
} else {
setPrimarySelection(value);
}
} else {
// 电视剧和综艺切换到"最近热门"时,重置二级分类为第一个选项
if ((type === 'tv' || type === 'show') && value === '最近热门') {
setPrimarySelection(value);
if (type === 'tv') {
setSecondarySelection('tv');
} else if (type === 'show') {
setSecondarySelection('show');
}
} else {
setPrimarySelection(value);
}
}
}
},
[primarySelection, type, customCategories]
);
const handleSecondaryChange = useCallback(
(value: string) => {
// 只有当值真正改变时才设置loading状态
if (value !== secondarySelection) {
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
setSecondarySelection(value);
}
},
[secondarySelection]
);
const handleMultiLevelChange = useCallback(
(values: Record<string, string>) => {
// 比较两个对象是否相同,忽略顺序
const isEqual = (
obj1: Record<string, string>,
obj2: Record<string, string>
) => {
const keys1 = Object.keys(obj1).sort();
const keys2 = Object.keys(obj2).sort();
if (keys1.length !== keys2.length) return false;
return keys1.every((key) => obj1[key] === obj2[key]);
};
// 如果相同则不设置loading状态
if (isEqual(values, multiLevelValues)) {
return;
}
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
setMultiLevelValues(values);
},
[multiLevelValues]
);
const handleWeekdayChange = useCallback((weekday: string) => {
setSelectedWeekday(weekday);
}, []);
const getPageTitle = () => {
// 根据 type 生成标题
return type === 'movie'
? '电影'
: type === 'tv'
? '电视剧'
: type === 'anime'
? '动漫'
: type === 'show'
? '综艺'
: '自定义';
};
const getPageDescription = () => {
if (type === 'anime' && primarySelection === '每日放送') {
return '来自 Bangumi 番组计划的精选内容';
}
return '来自豆瓣的精选内容';
};
const getActivePath = () => {
const params = new URLSearchParams();
if (type) params.set('type', type);
const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
return activePath;
};
return (
<PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题和选择器 */}
<div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
{/* 页面标题 */}
<div>
<h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
{getPageTitle()}
</h1>
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
{getPageDescription()}
</p>
</div>
{/* 选择器组件 */}
{type !== 'custom' ? (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show' | 'anime'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
onMultiLevelChange={handleMultiLevelChange}
onWeekdayChange={handleWeekdayChange}
/>
</div>
) : (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanCustomSelector
customCategories={customCategories}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
)}
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{/* 内容网格 */}
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={Number(item.id)}
rate={item.rate}
year={item.year}
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制tv 不控
isBangumi={
type === 'anime' && primarySelection === '每日放送'
}
/>
</div>
))}
</div>
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={(el) => {
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
</div>
</div>
</PageLayout>
);
}
export default function DoubanPage() {
return (
<Suspense>
<DoubanPageClient />
</Suspense>
);
}

197
src/app/globals.css Normal file
View File

@ -0,0 +1,197 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* Toast animations */
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-in {
animation-duration: 0.3s;
animation-timing-function: ease-out;
}
.slide-in-from-right-2 {
animation-name: slide-in-from-right;
}
}
:root {
--foreground-rgb: 255, 255, 255;
}
html,
body {
height: 100%;
overflow-x: hidden;
/* 阻止 iOS Safari 拉动回弹 */
overscroll-behavior: none;
}
body {
color: rgb(var(--foreground-rgb));
}
html:not(.dark) body {
background: linear-gradient(
180deg,
#e6f3fb 0%,
#eaf3f7 18%,
#f7f7f3 38%,
#e9ecef 60%,
#dbe3ea 80%,
#d3dde6 100%
);
background-attachment: fixed;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(107, 114, 128, 0.5);
}
/* 视频卡片悬停效果 */
.video-card-hover {
transition: transform 0.3s ease;
}
.video-card-hover:hover {
transform: scale(1.05);
}
/* 渐变遮罩 */
.gradient-overlay {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.8) 100%
);
}
/* 隐藏移动端(<768px垂直滚动条 */
@media (max-width: 767px) {
html,
body {
-ms-overflow-style: none; /* IE & Edge */
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
}
/* 隐藏所有滚动条(兼容 WebKit、Firefox、IE/Edge */
* {
-ms-overflow-style: none; /* IE & Edge */
scrollbar-width: none; /* Firefox */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* View Transitions API 动画 */
@keyframes slide-from-top {
from {
clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
}
to {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
}
@keyframes slide-from-bottom {
from {
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
}
to {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: both;
}
/*
切换时旧的视图不应该有动画它应该在下面等待被新的视图覆盖
这可以防止在动画完成前页面底部提前变色
*/
::view-transition-old(root) {
animation: none;
}
/* 从浅色到深色:新内容(深色)从顶部滑入 */
html.dark::view-transition-new(root) {
animation-name: slide-from-top;
}
/* 从深色到浅色:新内容(浅色)从底部滑入 */
html:not(.dark)::view-transition-new(root) {
animation-name: slide-from-bottom;
}
/* 强制播放器内部的 video 元素高度为 100%,并保持内容完整显示 */
div[data-media-provider] video {
height: 100%;
object-fit: contain;
}
.art-poster {
background-size: contain !important; /* 使图片完整展示 */
background-position: center center !important; /* 居中显示 */
background-repeat: no-repeat !important; /* 防止重复 */
background-color: #000 !important; /* 其余区域填充为黑色 */
}
/* 隐藏移动端竖屏时的 pip 按钮 */
@media (max-width: 768px) {
.art-control-pip {
display: none !important;
}
.art-control-fullscreenWeb {
display: none !important;
}
.art-control-volume {
display: none !important;
}
}

130
src/app/layout.tsx Normal file
View File

@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { getConfig } from '@/lib/config';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
import { ToastProvider } from '../components/Toast';
const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic';
// 动态生成 metadata支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const config = await getConfig();
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
if (storageType !== 'localstorage') {
siteName = config.SiteConfig.SiteName;
}
return {
title: siteName,
description: '影视聚合',
manifest: '/manifest.json',
};
}
export const viewport: Viewport = {
viewportFit: 'cover',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
let announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
let doubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
query: string;
}[];
if (storageType !== 'localstorage') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
doubanProxyType = config.SiteConfig.DoubanProxyType;
doubanProxy = config.SiteConfig.DoubanProxy;
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
fluidSearch = config.SiteConfig.FluidSearch;
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
DOUBAN_PROXY_TYPE: doubanProxyType,
DOUBAN_PROXY: doubanProxy,
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch,
};
return (
<html lang='zh-CN' suppressHydrationWarning>
<head>
<meta
name='viewport'
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
/>
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
</head>
<body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ToastProvider>
<SiteProvider siteName={siteName} announcement={announcement}>
{children}
<GlobalErrorIndicator />
</SiteProvider>
</ToastProvider>
</ThemeProvider>
</body>
</html>
);
}

1611
src/app/live/page.tsx Normal file

File diff suppressed because it is too large Load Diff

313
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,313 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { CURRENT_VERSION } from '@/lib/version';
import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
import MachineCode from '@/lib/machine-code';
import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
// 版本显示组件
function VersionDisplay() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (_) {
// do nothing
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
return (
<button
onClick={() =>
window.open('https://github.com/djteang/OrangeTV', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'text-yellow-600 dark:text-yellow-400'
: updateStatus === UpdateStatus.NO_UPDATE
? 'text-blue-600 dark:text-blue-400'
: ''
}`}
>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<>
<AlertCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
{updateStatus === UpdateStatus.NO_UPDATE && (
<>
<CheckCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
</div>
)}
</button>
);
}
function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [shouldAskUsername, setShouldAskUsername] = useState(false);
// 机器码相关状态
const [machineCode, setMachineCode] = useState<string>('');
const [deviceInfo, setDeviceInfo] = useState<string>('');
const [, setShowMachineCodeInput] = useState(false);
const [requireMachineCode, setRequireMachineCode] = useState(false);
const [machineCodeGenerated, setMachineCodeGenerated] = useState(false);
const [, setShowBindOption] = useState(false);
const [bindMachineCode, setBindMachineCode] = useState(false);
const { siteName } = useSite();
// 在客户端挂载后设置配置并生成机器码
useEffect(() => {
if (typeof window !== 'undefined') {
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
setShouldAskUsername(storageType && storageType !== 'localstorage');
// 生成机器码和设备信息
const generateMachineInfo = async () => {
if (MachineCode.isSupported()) {
try {
const code = await MachineCode.generateMachineCode();
const info = await MachineCode.getDeviceInfo();
setMachineCode(code);
setDeviceInfo(info);
setMachineCodeGenerated(true);
} catch (error) {
console.error('生成机器码失败:', error);
}
}
};
generateMachineInfo();
}
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!password || (shouldAskUsername && !username)) return;
try {
setLoading(true);
// 构建请求数据
const requestData: any = {
password,
...(shouldAskUsername ? { username } : {}),
};
// 如果需要机器码或用户选择绑定,则发送机器码
if ((requireMachineCode || bindMachineCode) && machineCode) {
requestData.machineCode = machineCode;
}
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
// 登录成功,如果用户选择绑定机器码,则绑定
if (bindMachineCode && machineCode && shouldAskUsername) {
try {
await fetch('/api/machine-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machineCode,
deviceInfo,
}),
});
} catch (bindError) {
console.error('绑定机器码失败:', bindError);
}
}
const redirect = searchParams.get('redirect') || '/';
router.replace(redirect);
} else if (res.status === 403) {
// 处理机器码相关错误
if (data.requireMachineCode) {
setRequireMachineCode(true);
setShowMachineCodeInput(true);
setError('该账户已绑定设备,请验证机器码');
} else if (data.machineCodeMismatch) {
setError('机器码不匹配,此账户只能在绑定的设备上使用');
} else {
setError(data.error || '访问被拒绝');
}
} else if (res.status === 409) {
// 机器码被其他用户绑定
setError(data.error || '机器码冲突');
} else if (res.status === 401) {
setError('用户名或密码错误');
} else {
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
<h1 className='text-blue-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
<form onSubmit={handleSubmit} className='space-y-8'>
{shouldAskUsername && (
<div className='relative'>
<input
id='username'
type='text'
autoComplete='username'
className='peer block w-full rounded-lg border-0 py-4 px-4 pt-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 focus:ring-2 focus:ring-blue-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur placeholder-transparent'
placeholder='用户名'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<label
htmlFor='username'
className={`absolute left-4 transition-all duration-200 pointer-events-none ${username
? 'top-1 text-xs text-blue-600 dark:text-blue-400'
: 'top-4 text-base text-gray-500 dark:text-gray-400 peer-focus:top-1 peer-focus:text-xs peer-focus:text-blue-600 peer-focus:dark:text-blue-400'
}`}
>
</label>
</div>
)}
<div className='relative'>
<input
id='password'
type='password'
autoComplete='current-password'
className='peer block w-full rounded-lg border-0 py-4 px-4 pt-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 focus:ring-2 focus:ring-blue-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur placeholder-transparent'
placeholder='密码'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label
htmlFor='password'
className={`absolute left-4 transition-all duration-200 pointer-events-none ${password
? 'top-1 text-xs text-blue-600 dark:text-blue-400'
: 'top-4 text-base text-gray-500 dark:text-gray-400 peer-focus:top-1 peer-focus:text-xs peer-focus:text-blue-600 peer-focus:dark:text-blue-400'
}`}
>
</label>
</div>
{/* 机器码信息显示 */}
{machineCodeGenerated && shouldAskUsername && (
<div className='space-y-4'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<Shield className='w-4 h-4 text-blue-600 dark:text-blue-400' />
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'></span>
</div>
<div className='space-y-2'>
<div className='text-xs font-mono text-gray-700 dark:text-gray-300 break-all'>
{MachineCode.formatMachineCode(machineCode)}
</div>
<div className='text-xs text-gray-600 dark:text-gray-400'>
: {deviceInfo}
</div>
</div>
</div>
{/* 绑定选项 */}
{!requireMachineCode && (
<div className='flex items-center space-x-3'>
<input
id='bindMachineCode'
type='checkbox'
checked={bindMachineCode}
onChange={(e) => setBindMachineCode(e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
<label htmlFor='bindMachineCode' className='text-sm text-gray-700 dark:text-gray-300'>
</label>
</div>
)}
</div>
)}
{error && (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
)}
{/* 登录按钮 */}
<button
type='submit'
disabled={
!password ||
loading ||
(shouldAskUsername && !username) ||
(machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
}
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
{/* 版本信息显示 */}
<VersionDisplay />
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LoginPageClient />
</Suspense>
);
}

523
src/app/page.tsx Normal file
View File

@ -0,0 +1,523 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
'use client';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react';
import {
BangumiCalendarData,
GetBangumiCalendarData,
} from '@/lib/bangumi.client';
// 客户端收藏 API
import {
clearAllFavorites,
getAllFavorites,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
function HomeClient() {
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
const [bangumiCalendarData, setBangumiCalendarData] = useState<
BangumiCalendarData[]
>([]);
const [loading, setLoading] = useState(true);
const { announcement } = useSite();
const [showAnnouncement, setShowAnnouncement] = useState(false);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
if (hasSeenAnnouncement !== announcement) {
setShowAnnouncement(true);
} else {
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
}
}
}, [announcement]);
// 收藏夹数据
type FavoriteItem = {
id: string;
source: string;
title: string;
poster: string;
episodes: number;
source_name: string;
currentEpisode?: number;
search_title?: string;
origin?: 'vod' | 'live';
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
useEffect(() => {
const fetchRecommendData = async () => {
try {
setLoading(true);
// 并行获取热门电影、热门剧集和热门综艺
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
await Promise.all([
getDoubanCategories({
kind: 'movie',
category: '热门',
type: '全部',
}),
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
GetBangumiCalendarData(),
]);
if (moviesData.code === 200) {
setHotMovies(moviesData.list);
}
if (tvShowsData.code === 200) {
setHotTvShows(tvShowsData.list);
}
if (varietyShowsData.code === 200) {
setHotVarietyShows(varietyShowsData.list);
}
setBangumiCalendarData(bangumiCalendarData);
} catch (error) {
console.error('获取推荐数据失败:', error);
} finally {
setLoading(false);
}
};
fetchRecommendData();
}, []);
// 处理收藏数据更新的函数
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
origin: fav?.origin,
} as FavoriteItem;
});
setFavoriteItems(sorted);
};
// 当切换到收藏夹时加载收藏数据
useEffect(() => {
if (activeTab !== 'favorites') return;
const loadFavorites = async () => {
const allFavorites = await getAllFavorites();
await updateFavoriteItems(allFavorites);
};
loadFavorites();
// 监听收藏更新事件
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
updateFavoriteItems(newFavorites);
}
);
return unsubscribe;
}, [activeTab]);
const handleCloseAnnouncement = (announcement: string) => {
setShowAnnouncement(false);
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
};
return (
<PageLayout>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 顶部 Tab 切换 */}
<div className='mb-8 flex justify-center'>
<CapsuleSwitch
options={[
{ label: '首页', value: 'home' },
{ label: '收藏夹', value: 'favorites' },
]}
active={activeTab}
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
/>
</div>
<div className='max-w-[95%] mx-auto'>
{activeTab === 'favorites' ? (
// 收藏夹视图
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{favoriteItems.length > 0 && (
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={async () => {
await clearAllFavorites();
setFavoriteItems([]);
}}
>
</button>
)}
</div>
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'>
<VideoCard
query={item.search_title}
{...item}
from='favorite'
type={item.episodes > 1 ? 'tv' : ''}
/>
</div>
))}
{favoriteItems.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
)}
</div>
</section>
) : (
// 首页视图
<>
{/* 继续观看 */}
<ContinueWatching />
{/* 热门电影 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=movie'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={movie.title}
poster={movie.poster}
douban_id={Number(movie.id)}
rate={movie.rate}
year={movie.year}
type='movie'
/>
</div>
))}
</ScrollableRow>
</section>
{/* 热门剧集 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=tv'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
{/* 每日新番放送 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=anime'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 展示当前日期的番剧
(() => {
// 获取当前日期对应的星期
const today = new Date();
const weekdays = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
];
const currentWeekday = weekdays[today.getDay()];
// 找到当前星期对应的番剧数据
const todayAnimes =
bangumiCalendarData.find(
(item) => item.weekday.en === currentWeekday
)?.items || [];
return todayAnimes.map((anime, index) => (
<div
key={`${anime.id}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={anime.name_cn || anime.name}
poster={
anime.images.large ||
anime.images.common ||
anime.images.medium ||
anime.images.small ||
anime.images.grid
}
douban_id={anime.id}
rate={anime.rating?.score?.toFixed(1) || ''}
year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true}
/>
</div>
));
})()}
</ScrollableRow>
</section>
{/* 热门综艺 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=show'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotVarietyShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
</>
)}
</div>
</div>
{announcement && showAnnouncement && (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
onTouchStart={(e) => {
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
onTouchMove={(e) => {
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
}
}}
onTouchEnd={(e) => {
// 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
>
<div
className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'
onTouchMove={(e) => {
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许内容区域的正常触摸操作
}}
>
<div className='flex justify-between items-start mb-4'>
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-blue-500 pb-1'>
</h3>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
aria-label='关闭'
></button>
</div>
<div className='mb-6'>
<div className='relative overflow-hidden rounded-lg mb-4 bg-blue-50 dark:bg-blue-900/20'>
<div className='absolute inset-y-0 left-0 w-1.5 bg-blue-500 dark:bg-blue-400'></div>
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
{announcement}
</p>
</div>
</div>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='w-full rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-blue-700 hover:to-blue-800 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800 transition-all duration-300 transform hover:-translate-y-0.5'
>
</button>
</div>
</div>
)}
</PageLayout>
);
}
export default function Home() {
return (
<Suspense>
<HomeClient />
</Suspense>
);
}

Some files were not shown because too many files have changed in this diff Show More