私人影库小雅初步实现
This commit is contained in:
@@ -193,6 +193,13 @@ export interface AdminConfig {
|
||||
LastSyncTime?: number;
|
||||
ItemCount?: number;
|
||||
};
|
||||
XiaoyaConfig?: {
|
||||
Enabled: boolean; // 是否启用
|
||||
ServerURL: string; // Alist 服务器地址
|
||||
Token?: string; // Token 认证(推荐)
|
||||
Username?: string; // 用户名认证(备选)
|
||||
Password?: string; // 密码认证(备选)
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { parseString } from 'xml2js';
|
||||
|
||||
export interface NFOMetadata {
|
||||
tmdbId?: number;
|
||||
title?: string;
|
||||
originalTitle?: string;
|
||||
year?: number;
|
||||
plot?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
mediaType: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 NFO 文件(XML 格式)
|
||||
*/
|
||||
export async function parseNFO(xmlContent: string): Promise<NFOMetadata | null> {
|
||||
return new Promise((resolve) => {
|
||||
parseString(xmlContent, { explicitArray: false }, (err, result) => {
|
||||
if (err || !result) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.movie || result.tvshow;
|
||||
if (!data) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: NFOMetadata = {
|
||||
tmdbId: data.tmdbid ? parseInt(data.tmdbid) : undefined,
|
||||
title: data.title,
|
||||
originalTitle: data.originaltitle,
|
||||
year: data.year ? parseInt(data.year) : undefined,
|
||||
plot: data.plot,
|
||||
rating: data.rating ? parseFloat(data.rating) : undefined,
|
||||
genres: Array.isArray(data.genre) ? data.genre : data.genre ? [data.genre] : [],
|
||||
mediaType: result.movie ? 'movie' : 'tv',
|
||||
};
|
||||
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { XiaoyaClient } from './xiaoya.client';
|
||||
import { parseNFO, NFOMetadata } from './nfo-parser';
|
||||
import { parseVideoFileName } from './video-parser';
|
||||
|
||||
export interface XiaoyaMetadata {
|
||||
tmdbId?: number;
|
||||
title: string;
|
||||
year?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
plot?: string;
|
||||
poster?: string;
|
||||
background?: string;
|
||||
mediaType: 'movie' | 'tv';
|
||||
source: 'folder' | 'nfo' | 'tmdb';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件夹名提取 TMDb ID 和年份
|
||||
* 格式: "标题 (年份) {tmdb-id}"
|
||||
*/
|
||||
function parseFolderName(folderName: string): {
|
||||
title?: string;
|
||||
year?: string;
|
||||
tmdbId?: number;
|
||||
} {
|
||||
const match = folderName.match(/^(.+?)\s*\((\d{4})\)\s*\{tmdb-(\d+)\}$/);
|
||||
if (match) {
|
||||
return {
|
||||
title: match[1].trim(),
|
||||
year: match[2],
|
||||
tmdbId: parseInt(match[3]),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 NFO 文件并解析
|
||||
*/
|
||||
async function findNFO(
|
||||
xiaoyaClient: XiaoyaClient,
|
||||
videoPath: string
|
||||
): Promise<NFOMetadata | null> {
|
||||
const pathParts = videoPath.split('/').filter(Boolean);
|
||||
const parentDir = pathParts.slice(0, -1).join('/');
|
||||
|
||||
const isInSeasonDir = /season\s*\d+/i.test(parentDir);
|
||||
|
||||
const nfoSearchPaths: string[] = [];
|
||||
|
||||
if (isInSeasonDir) {
|
||||
// 电视剧:查父级的 tvshow.nfo
|
||||
const grandParentDir = pathParts.slice(0, -2).join('/');
|
||||
nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`);
|
||||
} else {
|
||||
// 电影:查同级的 movie.nfo
|
||||
nfoSearchPaths.push(`/${parentDir}/movie.nfo`);
|
||||
}
|
||||
|
||||
for (const nfoPath of nfoSearchPaths) {
|
||||
try {
|
||||
const content = await xiaoyaClient.getFileContent(nfoPath);
|
||||
const metadata = await parseNFO(content);
|
||||
if (metadata) {
|
||||
return metadata;
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小雅视频的元数据
|
||||
*/
|
||||
export async function getXiaoyaMetadata(
|
||||
xiaoyaClient: XiaoyaClient,
|
||||
videoPath: string,
|
||||
tmdbApiKey?: string,
|
||||
tmdbProxy?: string
|
||||
): Promise<XiaoyaMetadata> {
|
||||
const pathParts = videoPath.split('/').filter(Boolean);
|
||||
const isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]);
|
||||
|
||||
// 确定元数据目录
|
||||
const metadataDir = isInSeasonDir
|
||||
? pathParts.slice(0, -2).join('/')
|
||||
: pathParts.slice(0, -1).join('/');
|
||||
|
||||
const folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2];
|
||||
|
||||
// 优先级 1: 从文件夹名提取 TMDb ID
|
||||
const folderInfo = parseFolderName(folderName);
|
||||
if (folderInfo.tmdbId) {
|
||||
const baseUrl = xiaoyaClient.getBaseURL();
|
||||
const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
|
||||
const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
|
||||
|
||||
// 尝试读取 NFO 获取详细信息
|
||||
const nfoData = await findNFO(xiaoyaClient, videoPath);
|
||||
|
||||
return {
|
||||
tmdbId: folderInfo.tmdbId,
|
||||
title: nfoData?.title || folderInfo.title || folderName,
|
||||
year: folderInfo.year,
|
||||
rating: nfoData?.rating,
|
||||
genres: nfoData?.genres,
|
||||
plot: nfoData?.plot,
|
||||
poster: posterUrl,
|
||||
background: backgroundUrl,
|
||||
mediaType: isInSeasonDir ? 'tv' : 'movie',
|
||||
source: nfoData ? 'nfo' : 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
// 优先级 2: 读取 NFO 文件
|
||||
const nfoData = await findNFO(xiaoyaClient, videoPath);
|
||||
if (nfoData && nfoData.tmdbId) {
|
||||
const baseUrl = xiaoyaClient.getBaseURL();
|
||||
const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
|
||||
const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
|
||||
|
||||
return {
|
||||
tmdbId: nfoData.tmdbId,
|
||||
title: nfoData.title || folderName,
|
||||
year: nfoData.year?.toString(),
|
||||
rating: nfoData.rating,
|
||||
genres: nfoData.genres,
|
||||
plot: nfoData.plot,
|
||||
poster: posterUrl,
|
||||
background: backgroundUrl,
|
||||
mediaType: nfoData.mediaType,
|
||||
source: 'nfo',
|
||||
};
|
||||
}
|
||||
|
||||
// 优先级 3: 实时搜索 TMDb
|
||||
if (tmdbApiKey) {
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const searchQuery = fileName
|
||||
.replace(/\.(mp4|mkv|avi|m3u8|flv|ts)$/i, '')
|
||||
.replace(/[\[\]()]/g, ' ')
|
||||
.trim();
|
||||
|
||||
const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
|
||||
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
|
||||
|
||||
if (tmdbResult.code === 200 && tmdbResult.result) {
|
||||
return {
|
||||
tmdbId: tmdbResult.result.id,
|
||||
title: tmdbResult.result.title || tmdbResult.result.name || folderName,
|
||||
year: tmdbResult.result.release_date?.substring(0, 4) ||
|
||||
tmdbResult.result.first_air_date?.substring(0, 4),
|
||||
rating: tmdbResult.result.vote_average,
|
||||
plot: tmdbResult.result.overview,
|
||||
poster: tmdbResult.result.poster_path
|
||||
? getTMDBImageUrl(tmdbResult.result.poster_path)
|
||||
: undefined,
|
||||
mediaType: tmdbResult.result.media_type,
|
||||
source: 'tmdb',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:只返回文件夹名
|
||||
return {
|
||||
title: folderName,
|
||||
mediaType: isInSeasonDir ? 'tv' : 'movie',
|
||||
source: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频集数列表
|
||||
*/
|
||||
export async function getXiaoyaEpisodes(
|
||||
xiaoyaClient: XiaoyaClient,
|
||||
videoPath: string
|
||||
): Promise<Array<{ path: string; title: string }>> {
|
||||
const pathParts = videoPath.split('/').filter(Boolean);
|
||||
const isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]);
|
||||
|
||||
if (isInSeasonDir) {
|
||||
// 电视剧:列出当前季的所有集
|
||||
const seasonDir = pathParts.slice(0, -1).join('/');
|
||||
const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`);
|
||||
|
||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||
const videoFiles = listResponse.content
|
||||
.filter(item =>
|
||||
!item.is_dir &&
|
||||
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return videoFiles.map(file => {
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
let title = file.name;
|
||||
|
||||
if (parsed.season && parsed.episode) {
|
||||
title = `S${parsed.season.toString().padStart(2, '0')}E${parsed.episode.toString().padStart(2, '0')}`;
|
||||
} else if (parsed.episode) {
|
||||
title = parsed.isOVA ? `OVA ${parsed.episode}` : `第${parsed.episode}集`;
|
||||
}
|
||||
|
||||
return {
|
||||
path: `/${seasonDir}/${file.name}`,
|
||||
title,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 电影:只有一个文件
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
return [{
|
||||
path: videoPath,
|
||||
title: fileName,
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Token 内存缓存
|
||||
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
export interface XiaoyaFile {
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface XiaoyaListResponse {
|
||||
content: XiaoyaFile[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export class XiaoyaClient {
|
||||
private token: string = '';
|
||||
|
||||
constructor(
|
||||
private baseURL: string,
|
||||
private username?: string,
|
||||
private password?: string,
|
||||
private configToken?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 使用账号密码登录获取Token
|
||||
*/
|
||||
static async login(
|
||||
baseURL: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`小雅登录失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 200 || !data.data?.token) {
|
||||
throw new Error('小雅登录失败: 未获取到Token');
|
||||
}
|
||||
|
||||
return data.data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的 Token 或重新登录
|
||||
*/
|
||||
private async getToken(): Promise<string> {
|
||||
// 如果配置了 Token,直接使用
|
||||
if (this.configToken) {
|
||||
return this.configToken;
|
||||
}
|
||||
|
||||
// 如果没有配置用户名密码,返回空字符串(guest 模式)
|
||||
if (!this.username || !this.password) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||
const cached = tokenCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存且未过期,直接返回
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
this.token = cached.token;
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// 否则重新登录
|
||||
console.log('[XiaoyaClient] Token 不存在或已过期,重新登录');
|
||||
this.token = await XiaoyaClient.login(
|
||||
this.baseURL,
|
||||
this.username,
|
||||
this.password
|
||||
);
|
||||
|
||||
// 缓存 Token,设置 1 小时过期
|
||||
tokenCache.set(cacheKey, {
|
||||
token: this.token,
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础 URL
|
||||
*/
|
||||
getBaseURL(): string {
|
||||
return this.baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
async listDirectory(path: string, page = 1, perPage = 100): Promise<XiaoyaListResponse> {
|
||||
const token = await this.getToken();
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/fs/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
page,
|
||||
per_page: perPage,
|
||||
refresh: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`小雅列表获取失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 200) {
|
||||
throw new Error(`小雅列表获取失败: ${data.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: data.data.content || [],
|
||||
total: data.data.total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文件
|
||||
*/
|
||||
async search(keyword: string, page = 1, perPage = 100): Promise<XiaoyaListResponse> {
|
||||
const token = await this.getToken();
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/fs/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent: '/',
|
||||
keywords: keyword,
|
||||
scope: 1, // 递归搜索
|
||||
page,
|
||||
per_page: perPage,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`小雅搜索失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 200) {
|
||||
throw new Error(`小雅搜索失败: ${data.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: data.data.content || [],
|
||||
total: data.data.total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
async getFileInfo(path: string): Promise<XiaoyaFile> {
|
||||
const token = await this.getToken();
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/fs/get`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`小雅文件信息获取失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 200) {
|
||||
throw new Error(`小雅文件信息获取失败: ${data.message}`);
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载链接
|
||||
*/
|
||||
async getDownloadUrl(path: string): Promise<string> {
|
||||
// Alist 的直接下载链接格式
|
||||
return `${this.baseURL}/d${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容(用于读取 NFO 等文本文件)
|
||||
*/
|
||||
async getFileContent(path: string): Promise<string> {
|
||||
const downloadUrl = await this.getDownloadUrl(path);
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`文件读取失败: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getFileInfo(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user