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
+211 -1
View File
@@ -212,7 +212,7 @@ const AlertModal = ({
return createPortal( return createPortal(
<div <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' isVisible ? 'opacity-100' : 'opacity-0'
}`} }`}
> >
@@ -3476,6 +3476,9 @@ const OpenListConfigComponent = ({
'hybrid' 'hybrid'
); );
const [disableVideoPreview, setDisableVideoPreview] = useState(false); const [disableVideoPreview, setDisableVideoPreview] = useState(false);
const [pathMetaRows, setPathMetaRows] = useState<
Array<{ path: string; category: string; refresh14m: boolean }>
>([]);
const [videos, setVideos] = useState<any[]>([]); const [videos, setVideos] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [scanProgress, setScanProgress] = useState<{ const [scanProgress, setScanProgress] = useState<{
@@ -3485,6 +3488,7 @@ const OpenListConfigComponent = ({
} | null>(null); } | null>(null);
const [correctDialogOpen, setCorrectDialogOpen] = useState(false); const [correctDialogOpen, setCorrectDialogOpen] = useState(false);
const [selectedVideo, setSelectedVideo] = useState<any | null>(null); const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
const [pathMetaDialogOpen, setPathMetaDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (config?.OpenListConfig) { if (config?.OpenListConfig) {
@@ -3514,6 +3518,14 @@ const OpenListConfigComponent = ({
setDisableVideoPreview( setDisableVideoPreview(
config.OpenListConfig.DisableVideoPreview || false 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]); }, [config]);
@@ -3548,6 +3560,22 @@ const OpenListConfigComponent = ({
const handleSave = async () => { const handleSave = async () => {
await withLoading('saveOpenList', async () => { await withLoading('saveOpenList', async () => {
try { 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', { const response = await fetch('/api/admin/openlist', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -3566,6 +3594,7 @@ const OpenListConfigComponent = ({
ScanInterval: scanInterval, ScanInterval: scanInterval,
ScanMode: scanMode, ScanMode: scanMode,
DisableVideoPreview: disableVideoPreview, DisableVideoPreview: disableVideoPreview,
PathMeta: pathMetaPayload,
}), }),
}); });
@@ -4070,6 +4099,187 @@ const OpenListConfigComponent = ({
</button> </button>
</div> </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'> <div className='flex gap-3'>
<button <button
onClick={handleCheckConnectivity} onClick={handleCheckConnectivity}
+16 -11
View File
@@ -6,6 +6,11 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { OpenListClient } from '@/lib/openlist.client'; import { OpenListClient } from '@/lib/openlist.client';
import {
normalizeOpenListPath,
normalizePathMetaMap,
type OpenListPathMetaMap,
} from '@/lib/openlist-path-meta';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -13,19 +18,14 @@ export const runtime = 'nodejs';
* 清理字符串中的 BOM 和其他不可见字符 * 清理字符串中的 BOM 和其他不可见字符
*/ */
function cleanPath(path: string): string { function cleanPath(path: string): string {
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符 return normalizeOpenListPath(path);
let cleaned = path }
.replace(/^\uFEFF/, '') // 移除开头的 BOM
.replace(/\uFEFF/g, '') // 移除所有 BOM
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
.trim(); // 移除首尾空白
// 移除末尾的 /(除非路径就是 /) function parsePathMeta(raw: unknown): OpenListPathMetaMap {
if (cleaned.length > 1 && cleaned.endsWith('/')) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
cleaned = cleaned.slice(0, -1); return {};
} }
return normalizePathMetaMap(raw as OpenListPathMetaMap);
return cleaned;
} }
/** /**
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
ScanInterval, ScanInterval,
ScanMode, ScanMode,
DisableVideoPreview, DisableVideoPreview,
PathMeta,
} = body; } = body;
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
@@ -80,6 +81,8 @@ export async function POST(request: NextRequest) {
} }
if (action === 'save') { if (action === 'save') {
const cleanedPathMeta = parsePathMeta(PathMeta);
// 如果功能未启用,允许保存空配置 // 如果功能未启用,允许保存空配置
if (!Enabled) { if (!Enabled) {
adminConfig.OpenListConfig = { adminConfig.OpenListConfig = {
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
ScanInterval: 0, ScanInterval: 0,
ScanMode: ScanMode || 'hybrid', ScanMode: ScanMode || 'hybrid',
DisableVideoPreview: DisableVideoPreview || false, DisableVideoPreview: DisableVideoPreview || false,
PathMeta: cleanedPathMeta,
}; };
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
@@ -193,6 +197,7 @@ export async function POST(request: NextRequest) {
ScanInterval: scanInterval, ScanInterval: scanInterval,
ScanMode: ScanMode || 'hybrid', ScanMode: ScanMode || 'hybrid',
DisableVideoPreview: DisableVideoPreview || false, DisableVideoPreview: DisableVideoPreview || false,
PathMeta: cleanedPathMeta,
}; };
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
+8
View File
@@ -205,6 +205,12 @@ export async function GET(request: NextRequest) {
// 3. 从 metainfo 中获取元数据 // 3. 从 metainfo 中获取元数据
const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); 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 = { const result = {
source: 'openlist', 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: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`),
episodes_titles: episodes.map((ep) => ep.title), episodes_titles: episodes.map((ep) => ep.title),
proxyMode: false, // openlist 源不使用代理模式 proxyMode: false, // openlist 源不使用代理模式
category: pathMetaResolved.category || undefined,
refresh14m: pathMetaResolved.refresh14m,
}; };
return NextResponse.json(result); return NextResponse.json(result);
+8
View File
@@ -223,11 +223,19 @@ export async function GET(request: NextRequest) {
return a.fileName.localeCompare(b.fileName); return a.fileName.localeCompare(b.fileName);
}); });
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
const pathMetaResolved = resolvePathMeta(
folderName,
openListConfig.PathMeta
);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
folder: folderName, folder: folderName,
episodes, episodes,
videoInfo, videoInfo,
category: pathMetaResolved.category,
refresh14m: pathMetaResolved.refresh14m,
}); });
} catch (error) { } catch (error) {
console.error('获取视频详情失败:', error); console.error('获取视频详情失败:', error);
+52 -32
View File
@@ -6,19 +6,23 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { requireFeaturePermission } from '@/lib/permissions'; import { requireFeaturePermission } from '@/lib/permissions';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { OpenListClient } from '@/lib/openlist.client';
import { import {
getCachedMetaInfo, getCachedMetaInfo,
MetaInfo, MetaInfo,
setCachedMetaInfo, setCachedMetaInfo,
} from '@/lib/openlist-cache'; } from '@/lib/openlist-cache';
import {
listPathMetaCategories,
resolvePathMeta,
} from '@/lib/openlist-path-meta';
import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { getTMDBImageUrl } from '@/lib/tmdb.search';
export const runtime = 'nodejs'; 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) { export async function GET(request: NextRequest) {
try { try {
@@ -34,6 +38,7 @@ export async function GET(request: NextRequest) {
const pageSize = parseInt(searchParams.get('pageSize') || '20'); const pageSize = parseInt(searchParams.get('pageSize') || '20');
const includeFailed = searchParams.get('includeFailed') === 'true'; const includeFailed = searchParams.get('includeFailed') === 'true';
const noCache = searchParams.get('noCache') === 'true'; const noCache = searchParams.get('noCache') === 'true';
const categoryFilter = (searchParams.get('category') || '').trim();
const config = await getConfig(); const config = await getConfig();
const openListConfig = config.OpenListConfig; const openListConfig = config.OpenListConfig;
@@ -46,16 +51,13 @@ export async function GET(request: NextRequest) {
!openListConfig.Password !openListConfig.Password
) { ) {
return NextResponse.json( return NextResponse.json(
{ error: 'OpenList 未配置或未启用', list: [], total: 0 }, { error: 'OpenList 未配置或未启用', list: [], total: 0, categories: [] },
{ status: 200 } { status: 200 }
); );
} }
const client = new OpenListClient( const pathMeta = openListConfig.PathMeta;
openListConfig.URL, const categories = listPathMetaCategories(pathMeta);
openListConfig.Username,
openListConfig.Password
);
// 读取 metainfo (从数据库或缓存) // 读取 metainfo (从数据库或缓存)
let metaInfo: MetaInfo | null = null; let metaInfo: MetaInfo | null = null;
@@ -102,6 +104,7 @@ export async function GET(request: NextRequest) {
details: (error as Error).message, details: (error as Error).message,
list: [], list: [],
total: 0, total: 0,
categories,
}, },
{ status: 200 } { status: 200 }
); );
@@ -110,7 +113,7 @@ export async function GET(request: NextRequest) {
if (!metaInfo) { if (!metaInfo) {
return NextResponse.json( return NextResponse.json(
{ error: '无数据', list: [], total: 0 }, { error: '无数据', list: [], total: 0, categories },
{ status: 200 } { status: 200 }
); );
} }
@@ -118,33 +121,43 @@ export async function GET(request: NextRequest) {
// 验证 metaInfo 结构 // 验证 metaInfo 结构
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
return NextResponse.json( return NextResponse.json(
{ error: 'metainfo.json 结构无效', list: [], total: 0 }, { error: 'metainfo.json 结构无效', list: [], total: 0, categories },
{ status: 200 } { status: 200 }
); );
} }
// 转换为数组并分页 // 转换为数组并分页
const allVideos = Object.entries(metaInfo.folders) let allVideos = Object.entries(metaInfo.folders)
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频 .filter(([, info]) => includeFailed || !info.failed)
.map( .map(([key, info]) => {
([key, info]) => { const pathMetaResolved = resolvePathMeta(info.folderName, pathMeta);
return { return {
id: key, id: key,
folder: info.folderName, folder: info.folderName,
tmdbId: info.tmdb_id, tmdbId: info.tmdb_id,
title: info.title, title: info.title,
poster: getTMDBImageUrl(info.poster_path), poster: getTMDBImageUrl(info.poster_path),
releaseDate: info.release_date, releaseDate: info.release_date,
overview: info.overview, overview: info.overview,
voteAverage: info.vote_average, voteAverage: info.vote_average,
mediaType: info.media_type, mediaType: info.media_type,
lastUpdated: info.last_updated, lastUpdated: info.last_updated,
failed: info.failed || false, failed: info.failed || false,
seasonNumber: info.season_number, seasonNumber: info.season_number,
seasonName: info.season_name, 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); allVideos.sort((a, b) => b.lastUpdated - a.lastUpdated);
@@ -161,11 +174,18 @@ export async function GET(request: NextRequest) {
page, page,
pageSize, pageSize,
totalPages: Math.ceil(total / pageSize), totalPages: Math.ceil(total / pageSize),
categories,
}); });
} catch (error) { } catch (error) {
console.error('获取视频列表失败:', error); console.error('获取视频列表失败:', error);
return NextResponse.json( return NextResponse.json(
{ error: '获取失败', details: (error as Error).message, list: [], total: 0 }, {
error: '获取失败',
details: (error as Error).message,
list: [],
total: 0,
categories: [],
},
{ status: 500 } { status: 500 }
); );
} }
+18 -2
View File
@@ -99,6 +99,12 @@ export async function GET(request: NextRequest) {
const folderPath = folderName; const folderPath = folderName;
const filePath = `${folderPath}/${fileName}`; const filePath = `${folderPath}/${fileName}`;
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
const pathMetaResolved = resolvePathMeta(
folderPath,
openListConfig.PathMeta
);
const client = new OpenListClient( const client = new OpenListClient(
openListConfig.URL, openListConfig.URL,
openListConfig.Username, openListConfig.Username,
@@ -130,7 +136,11 @@ export async function GET(request: NextRequest) {
throw new Error('获取到的播放链接为空'); throw new Error('获取到的播放链接为空');
} }
return NextResponse.json({ url: finalUrl }); return NextResponse.json({
url: finalUrl,
refresh14m: pathMetaResolved.refresh14m,
category: pathMetaResolved.category,
});
} }
// 检查URL是否为空 // 检查URL是否为空
@@ -183,6 +193,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
url: resolvedQualities[0].url, url: resolvedQualities[0].url,
qualities: resolvedQualities, qualities: resolvedQualities,
refresh14m: pathMetaResolved.refresh14m,
category: pathMetaResolved.category,
}); });
} }
@@ -215,7 +227,11 @@ export async function GET(request: NextRequest) {
throw new Error('获取到的播放链接为空'); throw new Error('获取到的播放链接为空');
} }
return NextResponse.json({ url: finalUrl }); return NextResponse.json({
url: finalUrl,
refresh14m: pathMetaResolved.refresh14m,
category: pathMetaResolved.category,
});
} }
// 检查URL是否为空 // 检查URL是否为空
+8
View File
@@ -1233,6 +1233,12 @@ export async function GET(request: NextRequest) {
// 3. 从 metainfo 中获取元数据 // 3. 从 metainfo 中获取元数据
const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); 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 = { const result = {
source: 'openlist', source: 'openlist',
@@ -1255,6 +1261,8 @@ export async function GET(request: NextRequest) {
), ),
episodes_titles: episodes.map((ep) => ep.title), episodes_titles: episodes.map((ep) => ep.title),
proxyMode: false, // openlist 源不使用代理模式 proxyMode: false, // openlist 源不使用代理模式
category: pathMetaResolved.category || undefined,
refresh14m: pathMetaResolved.refresh14m,
}; };
return NextResponse.json(result); return NextResponse.json(result);
+40 -2
View File
@@ -3052,6 +3052,7 @@ function PlayPageClient() {
/** /**
* 14 * 14
* xiaoyaopenlist detail.refresh14m true
*/ */
const startRefreshTimer = (hls: any, video: HTMLVideoElement) => { const startRefreshTimer = (hls: any, video: HTMLVideoElement) => {
// 清除旧定时器 // 清除旧定时器
@@ -3060,11 +3061,20 @@ function PlayPageClient() {
refreshTimerRef.current = null; refreshTimerRef.current = null;
} }
// 只对xiaoya源启动定时器 // 无原始可刷新 URL 则跳过
if (!currentXiaoyaUrlRef.current) { if (!currentXiaoyaUrlRef.current) {
return; 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分钟定时器'); console.log('[定时刷新] 启动14分钟定时器');
// 14分钟 = 840000毫秒 // 14分钟 = 840000毫秒
@@ -3168,8 +3178,15 @@ function PlayPageClient() {
if (isSpecialLazyPlayUrl) { if (isSpecialLazyPlayUrl) {
try { try {
// 保存原始URL(用于后续刷新) // 保存原始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; currentXiaoyaUrlRef.current = newUrl;
} else if (newUrl.startsWith('/api/openlist/play')) {
if (detailData?.refresh14m) {
currentXiaoyaUrlRef.current = newUrl;
} else {
currentXiaoyaUrlRef.current = '';
}
} }
// 添加 format=json 参数 // 添加 format=json 参数
@@ -3183,6 +3200,27 @@ function PlayPageClient() {
} }
if (data.url) { if (data.url) {
newUrl = 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) { if (data.qualities && data.qualities.length > 0) {
setVideoQualities(data.qualities); setVideoQualities(data.qualities);
+73 -2
View File
@@ -91,6 +91,9 @@ export default function PrivateLibraryPage() {
const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]); const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [mounted, setMounted] = 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 pageSize = 20;
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
const isFetchingRef = useRef(false); const isFetchingRef = useRef(false);
@@ -214,6 +217,7 @@ export default function PrivateLibraryPage() {
setHasMore(true); setHasMore(true);
setError(''); setError('');
setSelectedView('all'); setSelectedView('all');
setOpenlistCategory('all');
setLoading(false); setLoading(false);
setLoadingMore(false); setLoadingMore(false);
isFetchingRef.current = false; isFetchingRef.current = false;
@@ -232,6 +236,20 @@ export default function PrivateLibraryPage() {
isFetchingRef.current = false; isFetchingRef.current = false;
}, [selectedView]); }, [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(() => { useEffect(() => {
if (!isInitializedRef.current) return; if (!isInitializedRef.current) return;
@@ -456,7 +474,11 @@ export default function PrivateLibraryPage() {
setError(''); setError('');
const endpoint = sourceType === 'openlist' 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' : sourceType === 'xiaoya'
? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}` ? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}&sortBy=${sortBy}&sortOrder=${sortOrder}`; : `/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 状态 setVideos([]); // 小雅不使用 videos 状态
setHasMore(false); // 小雅不需要分页 setHasMore(false); // 小雅不需要分页
} else { } else {
if (sourceType === 'openlist' && Array.isArray(data.categories)) {
setOpenlistCategories(data.categories);
}
const newVideos = data.list || []; const newVideos = data.list || [];
if (isInitial) { if (isInitial) {
@@ -528,7 +554,7 @@ export default function PrivateLibraryPage() {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
} }
}; };
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig, sortBy, sortOrder]); }, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig, sortBy, sortOrder, openlistCategory]);
const handleVideoClick = (video: Video) => { const handleVideoClick = (video: Video) => {
// 构建source参数 // 构建source参数
@@ -666,6 +692,51 @@ export default function PrivateLibraryPage() {
</div> </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 媒体库分类选择器 */} {/* 第三级:Emby 媒体库分类选择器 */}
{sourceType === 'emby' && ( {sourceType === 'emby' && (
<div className='mb-6'> <div className='mb-6'>
+10
View File
@@ -166,6 +166,16 @@ export interface AdminConfig {
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟 ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认) ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接 DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
/**
* 路径元信息(最长前缀匹配 folder 路径)
* key: 规范化路径前缀,如 /videos 可匹配 /videos/某影片
*/
PathMeta?: {
[path: string]: {
category: string; // 分类名
refresh14m: boolean; // 该路径播放时是否启用 14 分钟 URL 续期
};
};
}; };
NetDiskConfig?: { NetDiskConfig?: {
Quark?: { Quark?: {
+3
View File
@@ -708,6 +708,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.OpenListConfig.OfflineDownloadPassword === undefined) { if (adminConfig.OpenListConfig.OfflineDownloadPassword === undefined) {
adminConfig.OpenListConfig.OfflineDownloadPassword = ''; adminConfig.OpenListConfig.OfflineDownloadPassword = '';
} }
if (adminConfig.OpenListConfig.PathMeta === undefined) {
adminConfig.OpenListConfig.PathMeta = {};
}
} }
// 用户信息已迁移到新版数据库 // 用户信息已迁移到新版数据库
+127
View File
@@ -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;
}
+8
View File
@@ -286,6 +286,12 @@ export async function getOpenListDetail(
// 3. 从 metainfo 中获取元数据 // 3. 从 metainfo 中获取元数据
const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); 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 { return {
source: 'openlist', source: 'openlist',
@@ -306,6 +312,8 @@ export async function getOpenListDetail(
), ),
episodes_titles: episodes.map((ep) => ep.title!), episodes_titles: episodes.map((ep) => ep.title!),
proxyMode: false, // openlist 源不使用代理模式 proxyMode: false, // openlist 源不使用代理模式
category: pathMetaResolved.category || undefined,
refresh14m: pathMetaResolved.refresh14m,
}; };
} }
+4
View File
@@ -310,6 +310,10 @@ export interface SearchResult {
rating?: number; // 评分 rating?: number; // 评分
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数) initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName) metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName)
/** OpenList 路径元信息:是否启用 14 分钟播放 URL 续期 */
refresh14m?: boolean;
/** OpenList 路径元信息:分类 */
category?: string;
} }
// 豆瓣数据结构 // 豆瓣数据结构