新增私人影视库功能
This commit is contained in:
@@ -91,6 +91,13 @@ export interface AdminConfig {
|
||||
cacheMinutes: number; // 缓存时间(分钟)
|
||||
cacheVersion: number; // CSS版本号(用于缓存控制)
|
||||
};
|
||||
OpenListConfig?: {
|
||||
URL: string; // OpenList 服务器地址
|
||||
Token: string; // 认证 Token
|
||||
RootPath: string; // 根目录路径,默认 "/"
|
||||
LastRefreshTime?: number; // 上次刷新时间戳
|
||||
ResourceCount?: number; // 资源数量
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// metainfo.json 缓存 (7天)
|
||||
interface MetaInfoCacheEntry {
|
||||
expiresAt: number;
|
||||
data: MetaInfo;
|
||||
}
|
||||
|
||||
// videoinfo.json 缓存 (1天)
|
||||
interface VideoInfoCacheEntry {
|
||||
expiresAt: number;
|
||||
data: VideoInfo;
|
||||
}
|
||||
|
||||
const METAINFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||
const VIDEOINFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1天
|
||||
|
||||
const METAINFO_CACHE: Map<string, MetaInfoCacheEntry> = new Map();
|
||||
const VIDEOINFO_CACHE: Map<string, VideoInfoCacheEntry> = new Map();
|
||||
|
||||
export interface MetaInfo {
|
||||
folders: {
|
||||
[folderName: string]: {
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
media_type: 'movie' | 'tv';
|
||||
last_updated: number;
|
||||
};
|
||||
};
|
||||
last_refresh: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
episodes: {
|
||||
[fileName: string]: {
|
||||
episode: number;
|
||||
season?: number;
|
||||
title?: string;
|
||||
parsed_from: 'videoinfo' | 'filename';
|
||||
};
|
||||
};
|
||||
last_updated: number;
|
||||
}
|
||||
|
||||
// MetaInfo 缓存操作
|
||||
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(rootPath);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(rootPath, {
|
||||
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateMetaInfoCache(rootPath: string): void {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
}
|
||||
|
||||
// VideoInfo 缓存操作
|
||||
export function getCachedVideoInfo(folderPath: string): VideoInfo | null {
|
||||
const entry = VIDEOINFO_CACHE.get(folderPath);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
VIDEOINFO_CACHE.delete(folderPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedVideoInfo(
|
||||
folderPath: string,
|
||||
data: VideoInfo
|
||||
): void {
|
||||
VIDEOINFO_CACHE.set(folderPath, {
|
||||
expiresAt: Date.now() + VIDEOINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateVideoInfoCache(folderPath: string): void {
|
||||
VIDEOINFO_CACHE.delete(folderPath);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface OpenListFile {
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
modified: string;
|
||||
sign?: string; // 临时下载签名
|
||||
raw_url?: string; // 完整下载链接
|
||||
thumb?: string;
|
||||
type: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface OpenListListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
content: OpenListFile[];
|
||||
total: number;
|
||||
readme: string;
|
||||
write: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenListGetResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: OpenListFile;
|
||||
}
|
||||
|
||||
export class OpenListClient {
|
||||
constructor(
|
||||
private baseURL: string,
|
||||
private token: string
|
||||
) {}
|
||||
|
||||
private getHeaders() {
|
||||
return {
|
||||
Authorization: this.token, // 不带 bearer
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
// 列出目录
|
||||
async listDirectory(
|
||||
path: string,
|
||||
page = 1,
|
||||
perPage = 100
|
||||
): Promise<OpenListListResponse> {
|
||||
const response = await fetch(`${this.baseURL}/api/fs/list`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: '',
|
||||
refresh: false,
|
||||
page,
|
||||
per_page: perPage,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
async getFile(path: string): Promise<OpenListGetResponse> {
|
||||
const response = await fetch(`${this.baseURL}/api/fs/get`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
async uploadFile(path: string, content: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseURL}/api/fs/put`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: this.token,
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'File-Path': encodeURIComponent(path),
|
||||
'As-Task': 'false',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenList 上传失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/fs/remove`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
names: [fileName],
|
||||
dir: dir,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList 删除失败: ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -45,6 +45,29 @@ interface TMDBTVAiringTodayResponse {
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
// 代理 agent 缓存,避免每次都创建新实例
|
||||
const proxyAgentCache = new Map<string, HttpsProxyAgent<string>>();
|
||||
|
||||
/**
|
||||
* 获取或创建代理 agent(复用连接池)
|
||||
*/
|
||||
function getProxyAgent(proxy: string): HttpsProxyAgent<string> {
|
||||
if (!proxyAgentCache.has(proxy)) {
|
||||
const agent = new HttpsProxyAgent(proxy, {
|
||||
// 增加超时时间
|
||||
timeout: 30000, // 30秒
|
||||
// 保持连接活跃
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 60000, // 60秒
|
||||
// 最大空闲连接数
|
||||
maxSockets: 10,
|
||||
maxFreeSockets: 5,
|
||||
});
|
||||
proxyAgentCache.set(proxy, agent);
|
||||
}
|
||||
return proxyAgentCache.get(proxy)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取即将上映的电影
|
||||
* @param apiKey - TMDB API Key
|
||||
@@ -65,7 +88,14 @@ export async function getTMDBUpcomingMovies(
|
||||
}
|
||||
|
||||
const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`;
|
||||
const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {};
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: getProxyAgent(proxy),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
@@ -105,7 +135,14 @@ export async function getTMDBUpcomingTVShows(
|
||||
|
||||
// 使用 on_the_air 接口获取正在播出的电视剧
|
||||
const url = `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`;
|
||||
const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {};
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: getProxyAgent(proxy),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
export interface TMDBSearchResult {
|
||||
id: number;
|
||||
title?: string; // 电影
|
||||
name?: string; // 电视剧
|
||||
poster_path: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
media_type: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
interface TMDBSearchResponse {
|
||||
results: TMDBSearchResult[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
// 代理 agent 缓存,避免每次都创建新实例
|
||||
const proxyAgentCache = new Map<string, HttpsProxyAgent<string>>();
|
||||
|
||||
/**
|
||||
* 获取或创建代理 agent(复用连接池)
|
||||
*/
|
||||
function getProxyAgent(proxy: string): HttpsProxyAgent<string> {
|
||||
if (!proxyAgentCache.has(proxy)) {
|
||||
const agent = new HttpsProxyAgent(proxy, {
|
||||
// 增加超时时间
|
||||
timeout: 30000, // 30秒
|
||||
// 保持连接活跃
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 60000, // 60秒
|
||||
// 最大空闲连接数
|
||||
maxSockets: 10,
|
||||
maxFreeSockets: 5,
|
||||
});
|
||||
proxyAgentCache.set(proxy, agent);
|
||||
}
|
||||
return proxyAgentCache.get(proxy)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 TMDB (电影+电视剧)
|
||||
*/
|
||||
export async function searchTMDB(
|
||||
apiKey: string,
|
||||
query: string,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; result: TMDBSearchResult | null }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
return { code: 400, result: null };
|
||||
}
|
||||
|
||||
// 使用 multi search 同时搜索电影和电视剧
|
||||
const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: getProxyAgent(proxy),
|
||||
// 设置请求超时(30秒)
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
// 即使不用代理也设置超时
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 搜索失败:', response.status, response.statusText);
|
||||
return { code: response.status, result: null };
|
||||
}
|
||||
|
||||
const data: TMDBSearchResponse = await response.json();
|
||||
|
||||
// 过滤出电影和电视剧,取第一个结果
|
||||
const validResults = data.results.filter(
|
||||
(item) => item.media_type === 'movie' || item.media_type === 'tv'
|
||||
);
|
||||
|
||||
if (validResults.length === 0) {
|
||||
return { code: 404, result: null };
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
result: validResults[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('TMDB 搜索异常:', error);
|
||||
return { code: 500, result: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TMDB 图片完整 URL
|
||||
*/
|
||||
export function getTMDBImageUrl(
|
||||
path: string | null,
|
||||
size: string = 'w500'
|
||||
): string {
|
||||
if (!path) return '';
|
||||
return `https://image.tmdb.org/t/p/${size}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import parseTorrentName from 'parse-torrent-name';
|
||||
|
||||
export interface ParsedVideoInfo {
|
||||
episode?: number;
|
||||
season?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析视频文件名
|
||||
*/
|
||||
export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||
try {
|
||||
const parsed = parseTorrentName(fileName);
|
||||
|
||||
// 如果 parse-torrent-name 成功解析出集数,直接返回
|
||||
if (parsed.episode) {
|
||||
return {
|
||||
episode: parsed.episode,
|
||||
season: parsed.season,
|
||||
title: parsed.title,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('parse-torrent-name 解析失败:', fileName, error);
|
||||
}
|
||||
|
||||
// 降级方案:使用多种正则模式提取集数
|
||||
// 按优先级排序:更具体的模式优先
|
||||
const patterns = [
|
||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
||||
/[Ss]\d+[Ee](\d+(?:\.\d+)?)/,
|
||||
// [01], (01), [01.5], (01.5) (支持小数) - 很具体
|
||||
/[\[\(](\d+(?:\.\d+)?)[\]\)]/,
|
||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||
/[Ee](\d+(?:\.\d+)?)/,
|
||||
// 第01集, 第1集, 第01话, 第1话, 第1.5集 (支持小数)
|
||||
/第(\d+(?:\.\d+)?)[集话]/,
|
||||
// _01_, -01-, _01.5_, -01.5- (支持小数)
|
||||
/[_\-](\d+(?:\.\d+)?)[_\-]/,
|
||||
// 01.mp4, 001.mp4, 01.5.mp4 (纯数字开头,支持小数) - 最不具体
|
||||
/^(\d+(?:\.\d+)?)[^\d.]/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = fileName.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const episode = parseFloat(match[1]);
|
||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
||||
return { episode };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有模式都失败,返回空对象(调用方会处理)
|
||||
return {};
|
||||
}
|
||||
Reference in New Issue
Block a user