新增私人影视库功能

This commit is contained in:
mtvpls
2025-12-22 00:47:45 +08:00
parent ec042b1231
commit 3c55bc9d1a
24 changed files with 2233 additions and 10 deletions
+7
View File
@@ -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 {
+96
View File
@@ -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);
}
+125
View File
@@ -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
View File
@@ -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}&region=${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);
+111
View File
@@ -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}`;
}
+57
View File
@@ -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 {};
}