diff --git a/src/app/api/admin/config_file/route.ts b/src/app/api/admin/config_file/route.ts index 26e6c35..6e51e02 100644 --- a/src/app/api/admin/config_file/route.ts +++ b/src/app/api/admin/config_file/route.ts @@ -79,6 +79,16 @@ export async function POST(request: NextRequest) { adminConfig = refineConfig(adminConfig); // 更新配置文件 await db.saveAdminConfig(adminConfig); + + // 清除短剧视频源缓存(因为配置文件可能包含新的视频源) + try { + await db.deleteGlobalValue('duanju'); + console.log('已清除短剧视频源缓存'); + } catch (error) { + console.error('清除短剧视频源缓存失败:', error); + // 不影响主流程,继续执行 + } + return NextResponse.json({ success: true, message: '配置文件更新成功', diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts index e6b19bb..73aafcb 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -85,6 +85,15 @@ export async function POST(req: NextRequest) { await db.saveAdminConfig(importData.data.adminConfig); await setCachedConfig(importData.data.adminConfig); + // 清除短剧视频源缓存(因为导入的配置可能包含不同的视频源) + try { + await db.deleteGlobalValue('duanju'); + console.log('已清除短剧视频源缓存'); + } catch (error) { + console.error('清除短剧视频源缓存失败:', error); + // 不影响主流程,继续执行 + } + // 导入用户数据 const userData = importData.data.userData; for (const username in userData) { diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index dafacfb..2efc3eb 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -226,6 +226,15 @@ export async function POST(request: NextRequest) { // 持久化到存储 await db.saveAdminConfig(adminConfig); + // 清除短剧视频源缓存(因为视频源发生了变动) + try { + await db.deleteGlobalValue('duanju'); + console.log('已清除短剧视频源缓存'); + } catch (error) { + console.error('清除短剧视频源缓存失败:', error); + // 不影响主流程,继续执行 + } + return NextResponse.json( { ok: true }, { diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 93db66a..31e4017 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -99,6 +99,15 @@ async function refreshConfig() { config.ConfigSubscribtion.LastCheck = new Date().toISOString(); config = refineConfig(config); await db.saveAdminConfig(config); + + // 清除短剧视频源缓存(因为配置文件可能包含新的视频源) + try { + await db.deleteGlobalValue('duanju'); + console.log('已清除短剧视频源缓存'); + } catch (error) { + console.error('清除短剧视频源缓存失败:', error); + // 不影响主流程,继续执行 + } } catch (e) { console.error('刷新配置失败:', e); } diff --git a/src/app/api/duanju/recommends/route.ts b/src/app/api/duanju/recommends/route.ts new file mode 100644 index 0000000..7b8fdf3 --- /dev/null +++ b/src/app/api/duanju/recommends/route.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextResponse } from 'next/server'; + +import { API_CONFIG, getCacheTime } from '@/lib/config'; +import { getDuanjuSources } from '@/lib/duanju'; +import { SearchResult } from '@/lib/types'; +import { cleanHtmlTags } from '@/lib/utils'; + +export const runtime = 'nodejs'; + +interface ApiSearchItem { + vod_id: string; + vod_name: string; + vod_pic: string; + vod_remarks?: string; + vod_play_url?: string; + vod_class?: string; + vod_year?: string; + vod_content?: string; + vod_douban_id?: number; + type_name?: string; +} + +interface CmsClassResponse { + class?: Array<{ + type_id: string | number; + type_name: string; + }>; +} + +/** + * 获取热播短剧推荐视频 + */ +export async function GET() { + try { + // 获取短剧视频源列表 + const sources = await getDuanjuSources(); + + if (!sources || sources.length === 0) { + return NextResponse.json({ + code: 200, + message: '暂无短剧视频源', + data: [], + }); + } + + // 取第一个视频源 + const firstSource = sources[0]; + console.log(`使用视频源: ${firstSource.name}`); + + // 获取该视频源的分类列表,找到短剧分类的ID + const classUrl = `${firstSource.api}?ac=list`; + const classResponse = await fetch(classUrl, { + headers: API_CONFIG.search.headers, + }); + + if (!classResponse.ok) { + throw new Error('获取分类列表失败'); + } + + const classData: CmsClassResponse = await classResponse.json(); + + // 找到短剧分类的ID + let duanjuTypeId: string | number | null = null; + if (classData.class && Array.isArray(classData.class)) { + const duanjuClass = classData.class.find((item) => { + const typeName = item.type_name?.toLowerCase() || ''; + return ( + typeName.includes('短剧') || + typeName.includes('短视频') || + typeName.includes('微短剧') + ); + }); + + if (duanjuClass) { + duanjuTypeId = duanjuClass.type_id; + } + } + + if (!duanjuTypeId) { + return NextResponse.json({ + code: 200, + message: '未找到短剧分类', + data: [], + }); + } + + console.log(`短剧分类ID: ${duanjuTypeId}`); + + // 请求该分类下的视频列表 + const videoListUrl = `${firstSource.api}?ac=videolist&t=${duanjuTypeId}&pg=1`; + const videoListResponse = await fetch(videoListUrl, { + headers: API_CONFIG.search.headers, + }); + + if (!videoListResponse.ok) { + throw new Error('获取视频列表失败'); + } + + const videoListData = await videoListResponse.json(); + + if ( + !videoListData || + !videoListData.list || + !Array.isArray(videoListData.list) || + videoListData.list.length === 0 + ) { + return NextResponse.json({ + code: 200, + message: '暂无短剧视频', + data: [], + }); + } + + // 处理视频数据 + const videos: SearchResult[] = videoListData.list.map((item: ApiSearchItem) => { + let episodes: string[] = []; + let titles: string[] = []; + + // 使用正则表达式从 vod_play_url 提取 m3u8 链接 + if (item.vod_play_url) { + // 先用 $$$ 分割 + const vod_play_url_array = item.vod_play_url.split('$$$'); + // 分集之间#分割,标题和播放链接 $ 分割 + vod_play_url_array.forEach((url: string) => { + const matchEpisodes: string[] = []; + const matchTitles: string[] = []; + const title_url_array = url.split('#'); + title_url_array.forEach((title_url: string) => { + const episode_title_url = title_url.split('$'); + if ( + episode_title_url.length === 2 && + episode_title_url[1].endsWith('.m3u8') + ) { + matchTitles.push(episode_title_url[0]); + matchEpisodes.push(episode_title_url[1]); + } + }); + if (matchEpisodes.length > episodes.length) { + episodes = matchEpisodes; + titles = matchTitles; + } + }); + } + + return { + id: item.vod_id.toString(), + title: item.vod_name.trim().replace(/\s+/g, ' '), + poster: item.vod_pic, + episodes, + episodes_titles: titles, + source: firstSource.key, + source_name: firstSource.name, + class: item.vod_class, + year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' : 'unknown', + desc: cleanHtmlTags(item.vod_content || ''), + type_name: item.type_name, + douban_id: item.vod_douban_id, + }; + }); + + // 过滤掉集数为 0 的结果,并限制返回数量 + const filteredVideos = videos + .filter((video) => video.episodes.length > 0) + .slice(0, 20); + + console.log(`返回 ${filteredVideos.length} 个短剧视频`); + + const cacheTime = await getCacheTime(); + return NextResponse.json( + { + code: 200, + message: '获取成功', + data: filteredVideos, + }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + }, + } + ); + } catch (error) { + console.error('获取热播短剧推荐失败:', error); + return NextResponse.json( + { + code: 500, + message: '获取热播短剧推荐失败', + error: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/duanju/sources/route.ts b/src/app/api/duanju/sources/route.ts new file mode 100644 index 0000000..ca0572f --- /dev/null +++ b/src/app/api/duanju/sources/route.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextResponse } from 'next/server'; + +import { getCacheTime } from '@/lib/config'; +import { getDuanjuSources } from '@/lib/duanju'; + +export const runtime = 'nodejs'; + +/** + * 获取包含短剧分类的视频源列表 + */ +export async function GET() { + try { + const sources = await getDuanjuSources(); + const cacheTime = await getCacheTime(); + + return NextResponse.json( + { + code: 200, + message: '获取成功', + data: sources, + }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + }, + } + ); + } catch (error) { + console.error('获取短剧视频源失败:', error); + return NextResponse.json( + { + code: 500, + message: '获取短剧视频源失败', + error: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b9adb09..c89965b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -34,6 +34,7 @@ function HomeClient() { const [hotMovies, setHotMovies] = useState([]); const [hotTvShows, setHotTvShows] = useState([]); const [hotVarietyShows, setHotVarietyShows] = useState([]); + const [hotDuanju, setHotDuanju] = useState([]); const [upcomingContent, setUpcomingContent] = useState([]); const [bangumiCalendarData, setBangumiCalendarData] = useState< BangumiCalendarData[] @@ -77,7 +78,7 @@ function HomeClient() { try { setLoading(true); - // 并行获取热门电影、热门剧集、热门综艺和番剧日历 + // 并行获取热门电影、热门剧集、热门综艺、番剧日历和热播短剧 const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] = await Promise.all([ getDoubanCategories({ @@ -104,6 +105,19 @@ function HomeClient() { setBangumiCalendarData(bangumiCalendarData); + // 获取热播短剧 + try { + const duanjuResponse = await fetch('/api/duanju/recommends'); + if (duanjuResponse.ok) { + const duanjuResult = await duanjuResponse.json(); + if (duanjuResult.code === 200 && duanjuResult.data) { + setHotDuanju(duanjuResult.data); + } + } + } catch (error) { + console.error('获取热播短剧数据失败:', error); + } + // 获取即将上映/播出内容(使用后端API缓存) try { const response = await fetch('/api/tmdb/upcoming'); @@ -309,6 +323,43 @@ function HomeClient() { + {/* 热播短剧 */} + {hotDuanju.length > 0 && ( +
+
+

+ 热播短剧 +

+
+ + {loading + ? Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+ )) + : hotDuanju.map((duanju) => ( +
+ +
+ ))} + +
+ )} + {/* 每日新番放送 */}
diff --git a/src/lib/db.ts b/src/lib/db.ts index 9cf127f..e838455 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -262,6 +262,26 @@ export class DbManager { throw new Error('存储类型不支持清空数据操作'); } } + + // ---------- 通用键值存储 ---------- + async getGlobalValue(key: string): Promise { + if (typeof (this.storage as any).getGlobalValue === 'function') { + return (this.storage as any).getGlobalValue(key); + } + return null; + } + + async setGlobalValue(key: string, value: string): Promise { + if (typeof (this.storage as any).setGlobalValue === 'function') { + await (this.storage as any).setGlobalValue(key, value); + } + } + + async deleteGlobalValue(key: string): Promise { + if (typeof (this.storage as any).deleteGlobalValue === 'function') { + await (this.storage as any).deleteGlobalValue(key); + } + } } // 导出默认实例 diff --git a/src/lib/duanju.ts b/src/lib/duanju.ts new file mode 100644 index 0000000..9c91de3 --- /dev/null +++ b/src/lib/duanju.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { API_CONFIG, getAvailableApiSites } from '@/lib/config'; +import { db } from '@/lib/db'; + +interface CmsClassResponse { + class?: Array<{ + type_id: string | number; + type_name: string; + }>; +} + +export interface DuanjuSource { + key: string; + name: string; + api: string; +} + +/** + * 获取包含短剧分类的视频源列表 + */ +export async function getDuanjuSources(): Promise { + try { + // 先查询数据库中是否有缓存 + const cachedData = await db.getGlobalValue('duanju'); + + if (cachedData !== null) { + // 有缓存,直接返回 + return cachedData ? JSON.parse(cachedData) : []; + } + + // 没有缓存,开始筛选 + console.log('开始筛选包含短剧分类的视频源...'); + const allSources = await getAvailableApiSites(); + const duanjuSources: DuanjuSource[] = []; + + // 并发���求所有视频源的分类列表 + const checkPromises = allSources.map(async (source) => { + try { + const classUrl = `${source.api}?ac=list`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(classUrl, { + headers: API_CONFIG.search.headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data: CmsClassResponse = await response.json(); + + // 检查是否有短剧分类 + if (data.class && Array.isArray(data.class)) { + const hasDuanju = data.class.some((item) => { + const typeName = item.type_name?.toLowerCase() || ''; + return ( + typeName.includes('短剧') || + typeName.includes('短视频') || + typeName.includes('微短剧') + ); + }); + + if (hasDuanju) { + return { + key: source.key, + name: source.name, + api: source.api, + }; + } + } + + return null; + } catch (error) { + // 请求失败或超时,忽略该源 + console.error(`检查视频源 ${source.name} 失败:`, error); + return null; + } + }); + + const results = await Promise.all(checkPromises); + + // 过滤掉null值 + results.forEach((result) => { + if (result) { + duanjuSources.push(result); + } + }); + + console.log(`找到 ${duanjuSources.length} 个包含短剧分类的视频源`); + + // 存入数据库(即使是空数组也要存) + await db.setGlobalValue('duanju', JSON.stringify(duanjuSources)); + + return duanjuSources; + } catch (error) { + console.error('获取短剧视频源失败:', error); + throw error; + } +} diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index e12d6d8..62c797a 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -495,4 +495,26 @@ export abstract class BaseRedisStorage implements IStorage { throw new Error('清空数据失败'); } } + + // ---------- 通用键值存储 ---------- + private globalValueKey(key: string) { + return `global:${key}`; + } + + async getGlobalValue(key: string): Promise { + const val = await this.withRetry(() => + this.client.get(this.globalValueKey(key)) + ); + return val ? ensureString(val) : null; + } + + async setGlobalValue(key: string, value: string): Promise { + await this.withRetry(() => + this.client.set(this.globalValueKey(key), ensureString(value)) + ); + } + + async deleteGlobalValue(key: string): Promise { + await this.withRetry(() => this.client.del(this.globalValueKey(key))); + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index b040c7e..59e35d6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -91,6 +91,11 @@ export interface IStorage { // 数据清理相关 clearAllData(): Promise; + + // 通用键值存储 + getGlobalValue(key: string): Promise; + setGlobalValue(key: string, value: string): Promise; + deleteGlobalValue(key: string): Promise; } // 搜索结果数据结构 diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index d7fa4b6..00ed464 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -393,6 +393,28 @@ export class UpstashRedisStorage implements IStorage { throw new Error('清空数据失败'); } } + + // ---------- 通用键值存储 ---------- + private globalValueKey(key: string) { + return `global:${key}`; + } + + async getGlobalValue(key: string): Promise { + const val = await withRetry(() => + this.client.get(this.globalValueKey(key)) + ); + return val ? ensureString(val) : null; + } + + async setGlobalValue(key: string, value: string): Promise { + await withRetry(() => + this.client.set(this.globalValueKey(key), ensureString(value)) + ); + } + + async deleteGlobalValue(key: string): Promise { + await withRetry(() => this.client.del(this.globalValueKey(key))); + } } // 单例 Upstash Redis 客户端