From d9e1b343e3f5998b2403b416b8aa3f1a5cfcbbe2 Mon Sep 17 00:00:00 2001
From: mtvpls <247332661+mtvpls@users.noreply.github.com>
Date: Sat, 18 Jul 2026 15:51:30 +0800
Subject: [PATCH] =?UTF-8?q?openlist=E5=A2=9E=E5=8A=A0=E8=B7=AF=E5=BE=84?=
=?UTF-8?q?=E5=85=83=E4=BF=A1=E6=81=AF=E9=85=8D=E7=BD=AE=E5=88=86=E7=B1=BB?=
=?UTF-8?q?=E5=92=8C=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E9=93=BE=E6=8E=A5?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 212 ++++++++++++++++++++++++++-
src/app/api/admin/openlist/route.ts | 27 ++--
src/app/api/detail/route.ts | 8 +
src/app/api/openlist/detail/route.ts | 8 +
src/app/api/openlist/list/route.ts | 84 +++++++----
src/app/api/openlist/play/route.ts | 20 ++-
src/app/api/source-detail/route.ts | 8 +
src/app/play/page.tsx | 42 +++++-
src/app/private-library/page.tsx | 75 +++++++++-
src/lib/admin.types.ts | 10 ++
src/lib/config.ts | 3 +
src/lib/openlist-path-meta.ts | 127 ++++++++++++++++
src/lib/special-sources-detail.ts | 8 +
src/lib/types.ts | 4 +
14 files changed, 586 insertions(+), 50 deletions(-)
create mode 100644 src/lib/openlist-path-meta.ts
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;
}
// 豆瓣数据结构