diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index db55eb6..1a9898a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -212,7 +212,7 @@ const AlertModal = ({ return createPortal(
@@ -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([]); 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(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 = ({
+
+
+

+ 路径元信息 +

+

+ 为指定路径下的影片设置分类,以及播放时是否自动刷新链接(约 14 分钟) + {pathMetaRows.length > 0 + ? ` · 已配置 ${pathMetaRows.length} 条` + : ''} +

+
+ +
+ + {pathMetaDialogOpen && + createPortal( +
setPathMetaDialogOpen(false)} + onTouchMove={(e) => e.preventDefault()} + onWheel={(e) => e.preventDefault()} + style={{ touchAction: 'none' }} + > +
e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + style={{ touchAction: 'auto' }} + > +
+

+ 路径元信息 +

+ +
+ +
+ 填写目录路径即可作用于其下所有影片(如 /videos)。更具体的路径优先。改完后点「保存配置」才会生效。 +
+ +
+ {pathMetaRows.length === 0 ? ( +

+ 暂无配置,点击下方「添加」开始 +

+ ) : ( + pathMetaRows.map((row, index) => ( +
+
+ { + 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' + /> +
+
+ { + 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' + /> +
+
+ + 播放自动刷新 + + +
+
+ +
+
+ )) + )} +
+ +
+ + +
+
+
, + document.body + )} +
)} + {/* OpenList 分类筛选(PathMeta) */} + {sourceType === 'openlist' && openlistCategories.length > 0 && ( +
+
+ 分类 +
+
+
+
+ + {openlistCategories.map((cat) => ( + + ))} +
+
+
+
+ )} + {/* 第三级:Emby 媒体库分类选择器 */} {sourceType === 'emby' && (
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index f6f4a9f..9e79673 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -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?: { diff --git a/src/lib/config.ts b/src/lib/config.ts index 333d1f1..c5827f4 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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 = {}; + } } // 用户信息已迁移到新版数据库 diff --git a/src/lib/openlist-path-meta.ts b/src/lib/openlist-path-meta.ts new file mode 100644 index 0000000..b15c3c7 --- /dev/null +++ b/src/lib/openlist-path-meta.ts @@ -0,0 +1,127 @@ +/** + * OpenList 路径元信息工具 + * PathMeta: { [path]: { category, refresh14m } } + * 匹配规则:规范化后最长前缀匹配 + * 例:配置 /videos 可匹配 /videos/某影片 + */ + +export interface OpenListPathMetaEntry { + category: string; + refresh14m: boolean; +} + +export type OpenListPathMetaMap = Record; + +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(); + 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; +} diff --git a/src/lib/special-sources-detail.ts b/src/lib/special-sources-detail.ts index 9742c9d..5d8e1d8 100644 --- a/src/lib/special-sources-detail.ts +++ b/src/lib/special-sources-detail.ts @@ -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, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index cf0dcd0..28700a0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; } // 豆瓣数据结构