diff --git a/package.json b/package.json index d11c946..5e6941d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", - "zod": "^3.24.1" + "@upstash/redis": "^1.25.0", + "zod": "^3.24.1", + "redis": "^4.6.7" }, "devDependencies": { "@commitlint/cli": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 260fb40..ae43736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) + '@upstash/redis': + specifier: ^1.25.0 + version: 1.35.1 '@vidstack/react': specifier: ^1.12.13 version: 1.12.13(@types/react@18.3.23)(react@18.3.1) @@ -53,6 +56,9 @@ importers: react-icons: specifier: ^5.4.0 version: 5.5.0(react@18.3.1) + redis: + specifier: ^4.6.7 + version: 4.7.1 swiper: specifier: ^11.2.8 version: 11.2.8 @@ -1558,6 +1564,35 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rollup/plugin-babel@5.3.1': resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -2026,6 +2061,9 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.35.1': + resolution: {integrity: sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA==} + '@vercel/blob@1.0.2': resolution: {integrity: sha512-Im/KeFH4oPx7UsM+QiteimnE07bIUD7JK6CBafI9Z0jRFogaialTBMiZj8EKk/30ctUYsrpIIyP9iIY1YxWnUQ==} engines: {node: '>=16.14'} @@ -2624,6 +2662,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3640,6 +3682,10 @@ packages: resolution: {integrity: sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==} engines: {node: '>= 4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5181,6 +5227,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5902,6 +5951,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} @@ -7868,6 +7920,32 @@ snapshots: dependencies: react: 18.3.1 + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rollup/plugin-babel@5.3.1(@babel/core@7.27.4)(@types/babel__core@7.20.5)(rollup@2.79.2)': dependencies: '@babel/core': 7.27.4 @@ -8366,6 +8444,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.0': optional: true + '@upstash/redis@1.35.1': + dependencies: + uncrypto: 0.1.3 + '@vercel/blob@1.0.2': dependencies: async-retry: 1.3.3 @@ -9114,6 +9196,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} code-block-writer@10.1.1: {} @@ -10192,6 +10276,8 @@ snapshots: generic-pool@3.4.2: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -11927,6 +12013,15 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -12737,6 +12832,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@7.8.0: {} undici@5.28.4: diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts new file mode 100644 index 0000000..72cdccb --- /dev/null +++ b/src/app/api/favorites/route.ts @@ -0,0 +1,153 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { db, Favorite } from '@/lib/db'; + +export const runtime = 'edge'; + +/** + * GET /api/favorites + * + * 支持两种调用方式: + * 1. 不带 query,返回全部收藏列表(Record)。 + * 2. 带 key=source+id,返回单条收藏(Favorite | null)。 + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + const user = searchParams.get('user'); + + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } + + // 查询单条收藏 + if (key) { + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + const fav = await db.getFavorite(user, source, id); + return NextResponse.json(fav, { status: 200 }); + } + + // 查询全部收藏 + const favorites = await db.getAllFavorites(user); + return NextResponse.json(favorites, { status: 200 }); + } catch (err) { + console.error('获取收藏失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +/** + * POST /api/favorites + * body: { user?: string; key: string; favorite: Favorite } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + user, + key, + favorite, + }: { user?: string; key: string; favorite: Favorite } = body; + + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } + + 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 favoriteWithoutUserId = { + ...favorite, + save_time: favorite.save_time ?? Date.now(), + } as Omit; + + await db.saveFavorite(user, source, id, favoriteWithoutUserId); + + 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 { + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + const user = searchParams.get('user'); + + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } + + if (key) { + // 删除单条 + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + await db.deleteFavorite(user, source, id); + } else { + // 清空全部 + const all = await db.getAllFavorites(user); + await Promise.all( + Object.keys(all).map(async (k) => { + const [s, i] = k.split('+'); + if (s && i) await db.deleteFavorite(user, s, i); + }) + ); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('删除收藏失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 3b5d174..70e80e8 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -1,31 +1,66 @@ +/* eslint-disable no-console */ import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + export const runtime = 'edge'; +// 读取存储类型环境变量,默认 localstorage +const STORAGE_TYPE = + (process.env.NEXT_PUBLIC_STORAGE_TYPE as string | undefined) || + 'localstorage'; + export async function POST(req: NextRequest) { try { - const result = process.env.PASSWORD; + // 本地 / localStorage 模式——仅校验固定密码 + if (STORAGE_TYPE === 'localstorage') { + const envPassword = process.env.PASSWORD; + + // 未配置 PASSWORD 时直接放行 + if (!envPassword) return NextResponse.json({ ok: true }); + + 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 } + ); + } - if (!result) { return NextResponse.json({ ok: true }); } - const { password } = await req.json(); - if (typeof password !== 'string') { + // 数据库 / redis 模式——校验用户名并尝试连接数据库 + const { username, password } = 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 }); } - const matched = password === result; + // 校验用户密码 + try { + const pass = await db.verifyUser(username, password); + if (!pass) { + return NextResponse.json( + { error: '用户名或密码错误' }, + { status: 401 } + ); + } - if (!matched) { - return NextResponse.json( - { ok: false, error: '密码错误' }, - { status: 401 } - ); + return NextResponse.json({ ok: true }); + } catch (err) { + console.error('数据库验证失败', err); + return NextResponse.json({ error: '数据库错误' }, { status: 500 }); } - - return NextResponse.json({ ok: true }); } catch (error) { + console.error('登录接口异常', error); return NextResponse.json({ error: '服务器错误' }, { status: 500 }); } } diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts index 70f1a23..d0146ea 100644 --- a/src/app/api/playrecords/route.ts +++ b/src/app/api/playrecords/route.ts @@ -7,9 +7,15 @@ import { PlayRecord } from '@/lib/db'; export const runtime = 'edge'; -export async function GET() { +export async function GET(request: NextRequest) { try { - const records = await db.getAllPlayRecords(); + const { searchParams } = new URL(request.url); + const user = searchParams.get('user'); + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } + + const records = await db.getAllPlayRecords(user); return NextResponse.json(records, { status: 200 }); } catch (err) { console.error('获取播放记录失败', err); @@ -23,7 +29,15 @@ export async function GET() { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { key, record }: { key: string; record: PlayRecord } = body; + const { + user, + key, + record, + }: { user?: string; key: string; record: PlayRecord } = body; + + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } if (!key || !record) { return NextResponse.json( @@ -61,7 +75,7 @@ export async function POST(request: NextRequest) { save_time: record.save_time, }; - await db.savePlayRecord(source, id, recordWithoutUserId); + await db.savePlayRecord(user, source, id, recordWithoutUserId); return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { @@ -72,3 +86,46 @@ export async function POST(request: NextRequest) { ); } } + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + const user = searchParams.get('user'); + + if (!user) { + return NextResponse.json({ error: 'Missing user' }, { status: 400 }); + } + + if (key) { + // 如果提供了 key,删除单条播放记录 + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + + await db.deletePlayRecord(user, source, id); + } else { + // 未提供 key,则清空全部播放记录 + // 目前 DbManager 没有对应方法,这里直接遍历删除 + const all = await db.getAllPlayRecords(user); + await Promise.all( + Object.keys(all).map(async (k) => { + const [s, i] = k.split('+'); + if (s && i) await db.deletePlayRecord(user, s, i); + }) + ); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('删除播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/searchhistory/route.ts b/src/app/api/searchhistory/route.ts new file mode 100644 index 0000000..1bf7100 --- /dev/null +++ b/src/app/api/searchhistory/route.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; + +export const runtime = 'edge'; + +// 最大保存条数(与客户端保持一致) +const HISTORY_LIMIT = 20; + +/** + * GET /api/searchhistory + * 返回 string[] + */ +export async function GET() { + try { + const history = await db.getSearchHistory(); + 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 { + 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(keyword); + + // 再次获取最新列表,确保客户端与服务端同步 + const history = await db.getSearchHistory(); + 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 + * + * 1. 不带 keyword -> 清空全部搜索历史 + * 2. 带 keyword= -> 删除单条关键字 + */ +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const kw = searchParams.get('keyword')?.trim(); + + await db.deleteSearchHistory(kw || undefined); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('删除搜索历史失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6892087..f02461c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,8 +33,25 @@ export default function RootLayout({ process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; + // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 + const runtimeConfig = { + STORAGE_TYPE: + process.env.STORAGE_TYPE || + process.env.NEXT_PUBLIC_STORAGE_TYPE || + 'localstorage', + }; + return ( + + {/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */} + {/* eslint-disable-next-line @next/next/no-sync-scripts */} +