openlist增加路径元信息配置分类和自动刷新链接功能
This commit is contained in:
@@ -6,6 +6,11 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
normalizeOpenListPath,
|
||||
normalizePathMetaMap,
|
||||
type OpenListPathMetaMap,
|
||||
} from '@/lib/openlist-path-meta';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -13,19 +18,14 @@ export const runtime = 'nodejs';
|
||||
* 清理字符串中的 BOM 和其他不可见字符
|
||||
*/
|
||||
function cleanPath(path: string): string {
|
||||
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符
|
||||
let cleaned = path
|
||||
.replace(/^\uFEFF/, '') // 移除开头的 BOM
|
||||
.replace(/\uFEFF/g, '') // 移除所有 BOM
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
|
||||
.trim(); // 移除首尾空白
|
||||
return normalizeOpenListPath(path);
|
||||
}
|
||||
|
||||
// 移除末尾的 /(除非路径就是 /)
|
||||
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.slice(0, -1);
|
||||
function parsePathMeta(raw: unknown): OpenListPathMetaMap {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
return normalizePathMetaMap(raw as OpenListPathMetaMap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
ScanInterval,
|
||||
ScanMode,
|
||||
DisableVideoPreview,
|
||||
PathMeta,
|
||||
} = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
@@ -80,6 +81,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (action === 'save') {
|
||||
const cleanedPathMeta = parsePathMeta(PathMeta);
|
||||
|
||||
// 如果功能未启用,允许保存空配置
|
||||
if (!Enabled) {
|
||||
adminConfig.OpenListConfig = {
|
||||
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
ScanInterval: 0,
|
||||
ScanMode: ScanMode || 'hybrid',
|
||||
DisableVideoPreview: DisableVideoPreview || false,
|
||||
PathMeta: cleanedPathMeta,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
@@ -193,6 +197,7 @@ export async function POST(request: NextRequest) {
|
||||
ScanInterval: scanInterval,
|
||||
ScanMode: ScanMode || 'hybrid',
|
||||
DisableVideoPreview: DisableVideoPreview || false,
|
||||
PathMeta: cleanedPathMeta,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
@@ -205,6 +205,12 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 3. 从 metainfo 中获取元数据
|
||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
|
||||
// folderName 为 metainfo 完整路径,PathMeta 最长前缀匹配
|
||||
const pathMetaResolved = resolvePathMeta(
|
||||
folderName,
|
||||
openListConfig.PathMeta
|
||||
);
|
||||
|
||||
const result = {
|
||||
source: 'openlist',
|
||||
@@ -218,6 +224,8 @@ export async function GET(request: NextRequest) {
|
||||
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`),
|
||||
episodes_titles: episodes.map((ep) => ep.title),
|
||||
proxyMode: false, // openlist 源不使用代理模式
|
||||
category: pathMetaResolved.category || undefined,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
};
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -223,11 +223,19 @@ export async function GET(request: NextRequest) {
|
||||
return a.fileName.localeCompare(b.fileName);
|
||||
});
|
||||
|
||||
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
|
||||
const pathMetaResolved = resolvePathMeta(
|
||||
folderName,
|
||||
openListConfig.PathMeta
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
folder: folderName,
|
||||
episodes,
|
||||
videoInfo,
|
||||
category: pathMetaResolved.category,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取视频详情失败:', error);
|
||||
|
||||
@@ -6,19 +6,23 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { requireFeaturePermission } from '@/lib/permissions';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
import {
|
||||
listPathMetaCategories,
|
||||
resolvePathMeta,
|
||||
} from '@/lib/openlist-path-meta';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/list?page=1&pageSize=20&includeFailed=false&noCache=false
|
||||
* GET /api/openlist/list?page=1&pageSize=20&includeFailed=false&noCache=false&category=
|
||||
* 获取私人影库视频列表
|
||||
* category: 分类名;__none__ 表示未分类
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -34,6 +38,7 @@ export async function GET(request: NextRequest) {
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const includeFailed = searchParams.get('includeFailed') === 'true';
|
||||
const noCache = searchParams.get('noCache') === 'true';
|
||||
const categoryFilter = (searchParams.get('category') || '').trim();
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
@@ -46,16 +51,13 @@ export async function GET(request: NextRequest) {
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 未配置或未启用', list: [], total: 0 },
|
||||
{ error: 'OpenList 未配置或未启用', list: [], total: 0, categories: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
const pathMeta = openListConfig.PathMeta;
|
||||
const categories = listPathMetaCategories(pathMeta);
|
||||
|
||||
// 读取 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = null;
|
||||
@@ -102,6 +104,7 @@ export async function GET(request: NextRequest) {
|
||||
details: (error as Error).message,
|
||||
list: [],
|
||||
total: 0,
|
||||
categories,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
@@ -110,7 +113,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!metaInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: '无数据', list: [], total: 0 },
|
||||
{ error: '无数据', list: [], total: 0, categories },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
@@ -118,33 +121,43 @@ export async function GET(request: NextRequest) {
|
||||
// 验证 metaInfo 结构
|
||||
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: 'metainfo.json 结构无效', list: [], total: 0 },
|
||||
{ error: 'metainfo.json 结构无效', list: [], total: 0, categories },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// 转换为数组并分页
|
||||
const allVideos = Object.entries(metaInfo.folders)
|
||||
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
||||
.map(
|
||||
([key, info]) => {
|
||||
return {
|
||||
id: key,
|
||||
folder: info.folderName,
|
||||
tmdbId: info.tmdb_id,
|
||||
title: info.title,
|
||||
poster: getTMDBImageUrl(info.poster_path),
|
||||
releaseDate: info.release_date,
|
||||
overview: info.overview,
|
||||
voteAverage: info.vote_average,
|
||||
mediaType: info.media_type,
|
||||
lastUpdated: info.last_updated,
|
||||
failed: info.failed || false,
|
||||
seasonNumber: info.season_number,
|
||||
seasonName: info.season_name,
|
||||
};
|
||||
}
|
||||
);
|
||||
let allVideos = Object.entries(metaInfo.folders)
|
||||
.filter(([, info]) => includeFailed || !info.failed)
|
||||
.map(([key, info]) => {
|
||||
const pathMetaResolved = resolvePathMeta(info.folderName, pathMeta);
|
||||
return {
|
||||
id: key,
|
||||
folder: info.folderName,
|
||||
tmdbId: info.tmdb_id,
|
||||
title: info.title,
|
||||
poster: getTMDBImageUrl(info.poster_path),
|
||||
releaseDate: info.release_date,
|
||||
overview: info.overview,
|
||||
voteAverage: info.vote_average,
|
||||
mediaType: info.media_type,
|
||||
lastUpdated: info.last_updated,
|
||||
failed: info.failed || false,
|
||||
seasonNumber: info.season_number,
|
||||
seasonName: info.season_name,
|
||||
category: pathMetaResolved.category,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
};
|
||||
});
|
||||
|
||||
// 分类筛选(完全匹配 PathMeta 后的 category)
|
||||
if (categoryFilter) {
|
||||
if (categoryFilter === '__none__') {
|
||||
allVideos = allVideos.filter((v) => !v.category);
|
||||
} else {
|
||||
allVideos = allVideos.filter((v) => v.category === categoryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
// 按更新时间倒序排序
|
||||
allVideos.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
@@ -161,11 +174,18 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
categories,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取视频列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message, list: [], total: 0 },
|
||||
{
|
||||
error: '获取失败',
|
||||
details: (error as Error).message,
|
||||
list: [],
|
||||
total: 0,
|
||||
categories: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,12 @@ export async function GET(request: NextRequest) {
|
||||
const folderPath = folderName;
|
||||
const filePath = `${folderPath}/${fileName}`;
|
||||
|
||||
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
|
||||
const pathMetaResolved = resolvePathMeta(
|
||||
folderPath,
|
||||
openListConfig.PathMeta
|
||||
);
|
||||
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
@@ -130,7 +136,11 @@ export async function GET(request: NextRequest) {
|
||||
throw new Error('获取到的播放链接为空');
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: finalUrl });
|
||||
return NextResponse.json({
|
||||
url: finalUrl,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
category: pathMetaResolved.category,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查URL是否为空
|
||||
@@ -183,6 +193,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
url: resolvedQualities[0].url,
|
||||
qualities: resolvedQualities,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
category: pathMetaResolved.category,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,7 +227,11 @@ export async function GET(request: NextRequest) {
|
||||
throw new Error('获取到的播放链接为空');
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: finalUrl });
|
||||
return NextResponse.json({
|
||||
url: finalUrl,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
category: pathMetaResolved.category,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查URL是否为空
|
||||
|
||||
@@ -1233,6 +1233,12 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 3. 从 metainfo 中获取元数据
|
||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
|
||||
// folderName 为 metainfo 完整路径,PathMeta 最长前缀匹配
|
||||
const pathMetaResolved = resolvePathMeta(
|
||||
folderName,
|
||||
openListConfig.PathMeta
|
||||
);
|
||||
|
||||
const result = {
|
||||
source: 'openlist',
|
||||
@@ -1255,6 +1261,8 @@ export async function GET(request: NextRequest) {
|
||||
),
|
||||
episodes_titles: episodes.map((ep) => ep.title),
|
||||
proxyMode: false, // openlist 源不使用代理模式
|
||||
category: pathMetaResolved.category || undefined,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
};
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
Reference in New Issue
Block a user