openlist增加路径元信息配置分类和自动刷新链接功能

This commit is contained in:
mtvpls
2026-07-18 15:51:30 +08:00
parent f742787044
commit d9e1b343e3
14 changed files with 586 additions and 50 deletions
+16 -11
View File
@@ -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);
+8
View File
@@ -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);
+8
View File
@@ -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);
+52 -32
View File
@@ -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 }
);
}
+18 -2
View File
@@ -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是否为空
+8
View File
@@ -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);