openlist增加路径元信息配置分类和自动刷新链接功能
This commit is contained in:
+211
-1
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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是否为空
|
||||||
|
|||||||
@@ -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
@@ -3052,6 +3052,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动14分钟定时刷新器
|
* 启动14分钟定时刷新器
|
||||||
|
* xiaoya:始终启用;openlist:仅当 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);
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户信息已迁移到新版数据库
|
// 用户信息已迁移到新版数据库
|
||||||
|
|||||||
@@ -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 中获取元数据
|
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 豆瓣数据结构
|
// 豆瓣数据结构
|
||||||
|
|||||||
Reference in New Issue
Block a user