openlist增加路径元信息配置分类和自动刷新链接功能
This commit is contained in:
+211
-1
@@ -212,7 +212,7 @@ const AlertModal = ({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${
|
||||
className={`fixed inset-0 bg-black bg-opacity-50 z-[10050] flex items-center justify-center p-4 transition-opacity duration-200 ${
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
@@ -3476,6 +3476,9 @@ const OpenListConfigComponent = ({
|
||||
'hybrid'
|
||||
);
|
||||
const [disableVideoPreview, setDisableVideoPreview] = useState(false);
|
||||
const [pathMetaRows, setPathMetaRows] = useState<
|
||||
Array<{ path: string; category: string; refresh14m: boolean }>
|
||||
>([]);
|
||||
const [videos, setVideos] = useState<any[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState<{
|
||||
@@ -3485,6 +3488,7 @@ const OpenListConfigComponent = ({
|
||||
} | null>(null);
|
||||
const [correctDialogOpen, setCorrectDialogOpen] = useState(false);
|
||||
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
||||
const [pathMetaDialogOpen, setPathMetaDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.OpenListConfig) {
|
||||
@@ -3514,6 +3518,14 @@ const OpenListConfigComponent = ({
|
||||
setDisableVideoPreview(
|
||||
config.OpenListConfig.DisableVideoPreview || false
|
||||
);
|
||||
const pathMeta = config.OpenListConfig.PathMeta || {};
|
||||
setPathMetaRows(
|
||||
Object.entries(pathMeta).map(([path, meta]) => ({
|
||||
path,
|
||||
category: meta?.category || '',
|
||||
refresh14m: Boolean(meta?.refresh14m),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -3548,6 +3560,22 @@ const OpenListConfigComponent = ({
|
||||
const handleSave = async () => {
|
||||
await withLoading('saveOpenList', async () => {
|
||||
try {
|
||||
// 路径元信息:序列化为 map(匹配时按最长前缀)
|
||||
if (pathMetaRows.some((row) => !(row.path || '').trim())) {
|
||||
throw new Error('路径元信息中的路径不能为空');
|
||||
}
|
||||
const pathMetaPayload: Record<
|
||||
string,
|
||||
{ category: string; refresh14m: boolean }
|
||||
> = {};
|
||||
for (const row of pathMetaRows) {
|
||||
const p = (row.path || '').trim();
|
||||
pathMetaPayload[p] = {
|
||||
category: (row.category || '').trim(),
|
||||
refresh14m: Boolean(row.refresh14m),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/openlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -3566,6 +3594,7 @@ const OpenListConfigComponent = ({
|
||||
ScanInterval: scanInterval,
|
||||
ScanMode: scanMode,
|
||||
DisableVideoPreview: disableVideoPreview,
|
||||
PathMeta: pathMetaPayload,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4070,6 +4099,187 @@ const OpenListConfigComponent = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||
路径元信息
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
为指定路径下的影片设置分类,以及播放时是否自动刷新链接(约 14 分钟)
|
||||
{pathMetaRows.length > 0
|
||||
? ` · 已配置 ${pathMetaRows.length} 条`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setPathMetaDialogOpen(true)}
|
||||
disabled={!enabled}
|
||||
className={`${buttonStyles.primary} text-sm ${
|
||||
!enabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pathMetaDialogOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[10002] flex items-center justify-center p-4'
|
||||
onClick={() => setPathMetaDialogOpen(false)}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<div
|
||||
className='w-full max-w-3xl max-h-[85vh] flex flex-col rounded-xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ touchAction: 'auto' }}
|
||||
>
|
||||
<div className='flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-base font-medium text-gray-900 dark:text-white'>
|
||||
路径元信息
|
||||
</h3>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setPathMetaDialogOpen(false)}
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-sm px-2 py-1'
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='px-5 py-3 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800'>
|
||||
填写目录路径即可作用于其下所有影片(如 /videos)。更具体的路径优先。改完后点「保存配置」才会生效。
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-2'>
|
||||
{pathMetaRows.length === 0 ? (
|
||||
<p className='text-sm text-gray-400 dark:text-gray-500 text-center py-8'>
|
||||
暂无配置,点击下方「添加」开始
|
||||
</p>
|
||||
) : (
|
||||
pathMetaRows.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='grid grid-cols-1 md:grid-cols-12 gap-2 items-center'
|
||||
>
|
||||
<div className='md:col-span-5'>
|
||||
<input
|
||||
type='text'
|
||||
value={row.path}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setPathMetaRows((rows) =>
|
||||
rows.map((r, i) =>
|
||||
i === index ? { ...r, path: value } : r
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder='路径,如 /videos'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-3'>
|
||||
<input
|
||||
type='text'
|
||||
value={row.category}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setPathMetaRows((rows) =>
|
||||
rows.map((r, i) =>
|
||||
i === index ? { ...r, category: value } : r
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder='分类,如 动漫'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-3 flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap'>
|
||||
播放自动刷新
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setPathMetaRows((rows) =>
|
||||
rows.map((r, i) =>
|
||||
i === index
|
||||
? { ...r, refresh14m: !r.refresh14m }
|
||||
: r
|
||||
)
|
||||
)
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
row.refresh14m
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
aria-label='播放自动刷新'
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
row.refresh14m
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='md:col-span-1 flex justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setPathMetaRows((rows) =>
|
||||
rows.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
className='px-2 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400'
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-3 px-5 py-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setPathMetaRows((rows) => [
|
||||
...rows,
|
||||
{ path: '', category: '', refresh14m: false },
|
||||
])
|
||||
}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
if (pathMetaRows.some((row) => !(row.path || '').trim())) {
|
||||
showError('路径不能为空', showAlert);
|
||||
return;
|
||||
}
|
||||
setPathMetaDialogOpen(false);
|
||||
}}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleCheckConnectivity}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+40
-2
@@ -3052,6 +3052,7 @@ function PlayPageClient() {
|
||||
|
||||
/**
|
||||
* 启动14分钟定时刷新器
|
||||
* xiaoya:始终启用;openlist:仅当 detail.refresh14m 为 true
|
||||
*/
|
||||
const startRefreshTimer = (hls: any, video: HTMLVideoElement) => {
|
||||
// 清除旧定时器
|
||||
@@ -3060,11 +3061,20 @@ function PlayPageClient() {
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 只对xiaoya源启动定时器
|
||||
// 无原始可刷新 URL 则跳过
|
||||
if (!currentXiaoyaUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// openlist 仅在 PathMeta.refresh14m 开启时续期;xiaoya 保持原逻辑
|
||||
const isOpenlistPlayUrl = currentXiaoyaUrlRef.current.startsWith(
|
||||
'/api/openlist/play'
|
||||
);
|
||||
if (isOpenlistPlayUrl && !detailRef.current?.refresh14m) {
|
||||
console.log('[定时刷新] OpenList 路径未开启 refresh14m,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[定时刷新] 启动14分钟定时器');
|
||||
|
||||
// 14分钟 = 840000毫秒
|
||||
@@ -3168,8 +3178,15 @@ function PlayPageClient() {
|
||||
if (isSpecialLazyPlayUrl) {
|
||||
try {
|
||||
// 保存原始URL(用于后续刷新)
|
||||
if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) {
|
||||
// xiaoya:始终保存;openlist:仅 refresh14m 时保存(否则不启 14 分钟续期)
|
||||
if (newUrl.startsWith('/api/xiaoya/play')) {
|
||||
currentXiaoyaUrlRef.current = newUrl;
|
||||
} else if (newUrl.startsWith('/api/openlist/play')) {
|
||||
if (detailData?.refresh14m) {
|
||||
currentXiaoyaUrlRef.current = newUrl;
|
||||
} else {
|
||||
currentXiaoyaUrlRef.current = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 format=json 参数
|
||||
@@ -3183,6 +3200,27 @@ function PlayPageClient() {
|
||||
}
|
||||
if (data.url) {
|
||||
newUrl = data.url;
|
||||
// play 响应可带回 refresh14m,覆盖 detail(配置热更新后仍一致)
|
||||
if (
|
||||
typeof data.refresh14m === 'boolean' &&
|
||||
detailData?.source === 'openlist'
|
||||
) {
|
||||
detailData.refresh14m = data.refresh14m;
|
||||
if (detailRef.current?.source === 'openlist') {
|
||||
detailRef.current.refresh14m = data.refresh14m;
|
||||
}
|
||||
if (data.refresh14m && currentXiaoyaUrlRef.current === '') {
|
||||
// 若此前因 detail 未带 flag 未保存,用原始 lazy url 兜底
|
||||
const originalLazy =
|
||||
detailData?.episodes?.[episodeIndex] || '';
|
||||
if (originalLazy.startsWith('/api/openlist/play')) {
|
||||
currentXiaoyaUrlRef.current = originalLazy;
|
||||
}
|
||||
}
|
||||
if (!data.refresh14m) {
|
||||
currentXiaoyaUrlRef.current = '';
|
||||
}
|
||||
}
|
||||
// 保存清晰度列表
|
||||
if (data.qualities && data.qualities.length > 0) {
|
||||
setVideoQualities(data.qualities);
|
||||
|
||||
@@ -91,6 +91,9 @@ export default function PrivateLibraryPage() {
|
||||
const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// OpenList 分类筛选(PathMeta 完全匹配后的 category)
|
||||
const [openlistCategory, setOpenlistCategory] = useState<string>('all');
|
||||
const [openlistCategories, setOpenlistCategories] = useState<string[]>([]);
|
||||
const pageSize = 20;
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const isFetchingRef = useRef(false);
|
||||
@@ -214,6 +217,7 @@ export default function PrivateLibraryPage() {
|
||||
setHasMore(true);
|
||||
setError('');
|
||||
setSelectedView('all');
|
||||
setOpenlistCategory('all');
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
isFetchingRef.current = false;
|
||||
@@ -232,6 +236,20 @@ export default function PrivateLibraryPage() {
|
||||
isFetchingRef.current = false;
|
||||
}, [selectedView]);
|
||||
|
||||
// 切换 OpenList 分类时重置状态
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
if (sourceType !== 'openlist') return;
|
||||
|
||||
setPage(1);
|
||||
setVideos([]);
|
||||
setHasMore(true);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
isFetchingRef.current = false;
|
||||
}, [openlistCategory, sourceType]);
|
||||
|
||||
// 切换排序时重置状态(但不在初始化时执行)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
@@ -456,7 +474,11 @@ export default function PrivateLibraryPage() {
|
||||
setError('');
|
||||
|
||||
const endpoint = sourceType === 'openlist'
|
||||
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
||||
? `/api/openlist/list?page=${page}&pageSize=${pageSize}${
|
||||
openlistCategory && openlistCategory !== 'all'
|
||||
? `&category=${encodeURIComponent(openlistCategory)}`
|
||||
: ''
|
||||
}`
|
||||
: sourceType === 'xiaoya'
|
||||
? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
|
||||
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||
@@ -482,6 +504,10 @@ export default function PrivateLibraryPage() {
|
||||
setVideos([]); // 小雅不使用 videos 状态
|
||||
setHasMore(false); // 小雅不需要分页
|
||||
} else {
|
||||
if (sourceType === 'openlist' && Array.isArray(data.categories)) {
|
||||
setOpenlistCategories(data.categories);
|
||||
}
|
||||
|
||||
const newVideos = data.list || [];
|
||||
|
||||
if (isInitial) {
|
||||
@@ -528,7 +554,7 @@ export default function PrivateLibraryPage() {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig, sortBy, sortOrder]);
|
||||
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig, sortBy, sortOrder, openlistCategory]);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
// 构建source参数
|
||||
@@ -666,6 +692,51 @@ export default function PrivateLibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenList 分类筛选(PathMeta) */}
|
||||
{sourceType === 'openlist' && openlistCategories.length > 0 && (
|
||||
<div className='mb-6'>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mb-2 px-4'>
|
||||
分类
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing'
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div className='flex gap-2 px-4 min-w-min'>
|
||||
<button
|
||||
onClick={() => setOpenlistCategory('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
||||
openlistCategory === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{openlistCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setOpenlistCategory(cat)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
||||
openlistCategory === cat
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三级:Emby 媒体库分类选择器 */}
|
||||
{sourceType === 'emby' && (
|
||||
<div className='mb-6'>
|
||||
|
||||
@@ -166,6 +166,16 @@ export interface AdminConfig {
|
||||
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
|
||||
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
|
||||
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
||||
/**
|
||||
* 路径元信息(最长前缀匹配 folder 路径)
|
||||
* key: 规范化路径前缀,如 /videos 可匹配 /videos/某影片
|
||||
*/
|
||||
PathMeta?: {
|
||||
[path: string]: {
|
||||
category: string; // 分类名
|
||||
refresh14m: boolean; // 该路径播放时是否启用 14 分钟 URL 续期
|
||||
};
|
||||
};
|
||||
};
|
||||
NetDiskConfig?: {
|
||||
Quark?: {
|
||||
|
||||
@@ -708,6 +708,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (adminConfig.OpenListConfig.OfflineDownloadPassword === undefined) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadPassword = '';
|
||||
}
|
||||
if (adminConfig.OpenListConfig.PathMeta === undefined) {
|
||||
adminConfig.OpenListConfig.PathMeta = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 用户信息已迁移到新版数据库
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* OpenList 路径元信息工具
|
||||
* PathMeta: { [path]: { category, refresh14m } }
|
||||
* 匹配规则:规范化后最长前缀匹配
|
||||
* 例:配置 /videos 可匹配 /videos/某影片
|
||||
*/
|
||||
|
||||
export interface OpenListPathMetaEntry {
|
||||
category: string;
|
||||
refresh14m: boolean;
|
||||
}
|
||||
|
||||
export type OpenListPathMetaMap = Record<string, OpenListPathMetaEntry>;
|
||||
|
||||
export const EMPTY_PATH_META: OpenListPathMetaEntry = {
|
||||
category: '',
|
||||
refresh14m: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化路径:去 BOM / 零宽字符、trim、去掉末尾 /(根路径 / 除外)
|
||||
*/
|
||||
export function normalizeOpenListPath(path: string): string {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let cleaned = path
|
||||
// UTF-8 BOM
|
||||
.replace(/^/, '')
|
||||
.replace(//g, '')
|
||||
// zero-width chars U+200B-U+200D, U+FEFF
|
||||
.replace(/[-]/g, '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
// 去掉末尾 /(除非就是 /)
|
||||
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.slice(0, -1);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 PathMeta map 的 key,合并重复路径(后者覆盖前者)
|
||||
*/
|
||||
export function normalizePathMetaMap(
|
||||
pathMeta: OpenListPathMetaMap | undefined | null
|
||||
): OpenListPathMetaMap {
|
||||
if (!pathMeta || typeof pathMeta !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: OpenListPathMetaMap = {};
|
||||
for (const [rawPath, entry] of Object.entries(pathMeta)) {
|
||||
const pathKey = normalizeOpenListPath(rawPath);
|
||||
if (!pathKey) continue;
|
||||
result[pathKey] = {
|
||||
category: typeof entry?.category === 'string' ? entry.category.trim() : '',
|
||||
refresh14m: Boolean(entry?.refresh14m),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最长前缀匹配解析路径元信息
|
||||
* - 全等命中
|
||||
* - 或以 key + '/' 为前缀(避免 /videos 误匹配 /videos2)
|
||||
* - 多条命中时取最长 key
|
||||
*/
|
||||
export function resolvePathMeta(
|
||||
folderPath: string,
|
||||
pathMeta: OpenListPathMetaMap | undefined | null
|
||||
): OpenListPathMetaEntry {
|
||||
const normalized = normalizeOpenListPath(folderPath);
|
||||
if (!normalized || !pathMeta) {
|
||||
return { ...EMPTY_PATH_META };
|
||||
}
|
||||
|
||||
const map = normalizePathMetaMap(pathMeta);
|
||||
let bestKey = '';
|
||||
let best: OpenListPathMetaEntry | null = null;
|
||||
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
if (!key) continue;
|
||||
const matched =
|
||||
normalized === key ||
|
||||
(key === '/'
|
||||
? normalized.startsWith('/')
|
||||
: normalized.startsWith(key + '/'));
|
||||
if (!matched) continue;
|
||||
if (key.length >= bestKey.length) {
|
||||
bestKey = key;
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
return { ...EMPTY_PATH_META };
|
||||
}
|
||||
|
||||
return {
|
||||
category: best.category || '',
|
||||
refresh14m: Boolean(best.refresh14m),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 PathMeta 提取非空分类列表(去重、保序)
|
||||
*/
|
||||
export function listPathMetaCategories(
|
||||
pathMeta: OpenListPathMetaMap | undefined | null
|
||||
): string[] {
|
||||
const map = normalizePathMetaMap(pathMeta);
|
||||
const seen = new Set<string>();
|
||||
const categories: string[] = [];
|
||||
for (const entry of Object.values(map)) {
|
||||
const cat = (entry.category || '').trim();
|
||||
if (cat && !seen.has(cat)) {
|
||||
seen.add(cat);
|
||||
categories.push(cat);
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
@@ -286,6 +286,12 @@ export async function getOpenListDetail(
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
return {
|
||||
source: 'openlist',
|
||||
@@ -306,6 +312,8 @@ export async function getOpenListDetail(
|
||||
),
|
||||
episodes_titles: episodes.map((ep) => ep.title!),
|
||||
proxyMode: false, // openlist 源不使用代理模式
|
||||
category: pathMetaResolved.category || undefined,
|
||||
refresh14m: pathMetaResolved.refresh14m,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,10 @@ export interface SearchResult {
|
||||
rating?: number; // 评分
|
||||
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
||||
metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName)
|
||||
/** OpenList 路径元信息:是否启用 14 分钟播放 URL 续期 */
|
||||
refresh14m?: boolean;
|
||||
/** OpenList 路径元信息:分类 */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// 豆瓣数据结构
|
||||
|
||||
Reference in New Issue
Block a user