首页增加短剧推荐
This commit is contained in:
@@ -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: '配置文件更新成功',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
194
src/app/api/duanju/recommends/route.ts
Normal file
194
src/app/api/duanju/recommends/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/duanju/sources/route.ts
Normal file
41
src/app/api/duanju/sources/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ function HomeClient() {
|
||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||
const [hotDuanju, setHotDuanju] = useState<any[]>([]);
|
||||
const [upcomingContent, setUpcomingContent] = useState<TMDBItem[]>([]);
|
||||
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() {
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热播短剧 */}
|
||||
{hotDuanju.length > 0 && (
|
||||
<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>
|
||||
</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='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||||
</div>
|
||||
))
|
||||
: hotDuanju.map((duanju) => (
|
||||
<div
|
||||
key={duanju.id + duanju.source}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
poster={duanju.poster}
|
||||
title={duanju.title}
|
||||
year={duanju.year}
|
||||
type='tv'
|
||||
from='douban'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
|
||||
@@ -262,6 +262,26 @@ export class DbManager {
|
||||
throw new Error('存储类型不支持清空数据操作');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 通用键值存储 ----------
|
||||
async getGlobalValue(key: string): Promise<string | null> {
|
||||
if (typeof (this.storage as any).getGlobalValue === 'function') {
|
||||
return (this.storage as any).getGlobalValue(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||
if (typeof (this.storage as any).setGlobalValue === 'function') {
|
||||
await (this.storage as any).setGlobalValue(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteGlobalValue === 'function') {
|
||||
await (this.storage as any).deleteGlobalValue(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
|
||||
104
src/lib/duanju.ts
Normal file
104
src/lib/duanju.ts
Normal file
@@ -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<DuanjuSource[]> {
|
||||
try {
|
||||
// 先查询数据库中是否有缓存
|
||||
const cachedData = await db.getGlobalValue('duanju');
|
||||
|
||||
if (cachedData !== null) {
|
||||
// 有缓存,直接返回
|
||||
return cachedData ? JSON.parse(cachedData) : [];
|
||||
}
|
||||
|
||||
// 没有缓存,开始筛选
|
||||
console.log('开始筛选包含短剧分类的视频源...');
|
||||
const allSources = await getAvailableApiSites();
|
||||
const duanjuSources: DuanjuSource[] = [];
|
||||
|
||||
// 并发<E5B9B6><E58F91><EFBFBD>求所有视频源的分类列表
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
const val = await this.withRetry(() =>
|
||||
this.client.get(this.globalValueKey(key))
|
||||
);
|
||||
return val ? ensureString(val) : null;
|
||||
}
|
||||
|
||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.globalValueKey(key), ensureString(value))
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.del(this.globalValueKey(key)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ export interface IStorage {
|
||||
|
||||
// 数据清理相关
|
||||
clearAllData(): Promise<void>;
|
||||
|
||||
// 通用键值存储
|
||||
getGlobalValue(key: string): Promise<string | null>;
|
||||
setGlobalValue(key: string, value: string): Promise<void>;
|
||||
deleteGlobalValue(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
|
||||
@@ -393,6 +393,28 @@ export class UpstashRedisStorage implements IStorage {
|
||||
throw new Error('清空数据失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 通用键值存储 ----------
|
||||
private globalValueKey(key: string) {
|
||||
return `global:${key}`;
|
||||
}
|
||||
|
||||
async getGlobalValue(key: string): Promise<string | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.globalValueKey(key))
|
||||
);
|
||||
return val ? ensureString(val) : null;
|
||||
}
|
||||
|
||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.globalValueKey(key), ensureString(value))
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.globalValueKey(key)));
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
|
||||
Reference in New Issue
Block a user