Merge branch 'dev'
This commit is contained in:
+12
-1
@@ -40,6 +40,9 @@ export interface AdminConfig {
|
||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
||||
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
||||
// 求片功能配置
|
||||
EnableMovieRequest?: boolean; // 启用求片功能
|
||||
MovieRequestCooldown?: number; // 求片冷却时间(秒),默认3600
|
||||
// OIDC配置
|
||||
EnableOIDCLogin?: boolean; // 启用OIDC登录
|
||||
EnableOIDCRegistration?: boolean; // 启用OIDC注册
|
||||
@@ -106,7 +109,8 @@ export interface AdminConfig {
|
||||
URL: string; // OpenList 服务器地址
|
||||
Username: string; // 账号(用于登录获取Token)
|
||||
Password: string; // 密码(用于登录获取Token)
|
||||
RootPath: string; // 根目录路径,默认 "/"
|
||||
RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除)
|
||||
RootPaths?: string[]; // 新字段:多根目录路径列表
|
||||
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
||||
LastRefreshTime?: number; // 上次刷新时间戳
|
||||
ResourceCount?: number; // 资源数量
|
||||
@@ -192,6 +196,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -49,28 +49,30 @@ export interface VideoInfo {
|
||||
last_updated: number;
|
||||
}
|
||||
|
||||
// MetaInfo 缓存操作
|
||||
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(rootPath);
|
||||
// MetaInfo 缓存操作(使用固定键)
|
||||
const METAINFO_CACHE_KEY = 'openlist_meta';
|
||||
|
||||
export function getCachedMetaInfo(): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(METAINFO_CACHE_KEY);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(rootPath, {
|
||||
export function setCachedMetaInfo(data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(METAINFO_CACHE_KEY, {
|
||||
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateMetaInfoCache(rootPath: string): void {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
export function invalidateMetaInfoCache(): void {
|
||||
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||
}
|
||||
|
||||
// VideoInfo 缓存操作
|
||||
|
||||
+120
-10
@@ -20,6 +20,65 @@ import {
|
||||
import { parseSeasonFromTitle } from '@/lib/season-parser';
|
||||
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
|
||||
import parseTorrentName from 'parse-torrent-name';
|
||||
import type { AdminConfig } from '@/lib/admin.types';
|
||||
|
||||
/**
|
||||
* 获取根目录列表(兼容新旧配置)
|
||||
*/
|
||||
function getRootPaths(openListConfig: AdminConfig['OpenListConfig']): string[] {
|
||||
if (!openListConfig) {
|
||||
return ['/'];
|
||||
}
|
||||
|
||||
// 如果有新字段 RootPaths,直接使用
|
||||
if (openListConfig.RootPaths && openListConfig.RootPaths.length > 0) {
|
||||
return openListConfig.RootPaths;
|
||||
}
|
||||
|
||||
// 如果只tPath,返回单元素数组
|
||||
if (openListConfig.RootPath) {
|
||||
return [openListConfig.RootPath];
|
||||
}
|
||||
|
||||
// 默认值
|
||||
return ['/'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移旧版单根目录配置到多根目录
|
||||
*/
|
||||
async function migrateToMultiRoot(openListConfig: NonNullable<AdminConfig['OpenListConfig']>): Promise<void> {
|
||||
const oldRootPath = openListConfig.RootPath!;
|
||||
|
||||
console.log('[OpenList Migration] 检测到旧版配置,开始迁移...');
|
||||
|
||||
// 1. 读取现有 metainfo
|
||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoContent) {
|
||||
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
||||
|
||||
// 2. 迁移 folderName:加上原根路径前缀
|
||||
for (const [key, info] of Object.entries(metaInfo.folders)) {
|
||||
const oldFolderName = info.folderName;
|
||||
const newFolderName = `${oldRootPath}${oldRootPath.endsWith('/') ? '' : '/'}${oldFolderName}`;
|
||||
info.folderName = newFolderName;
|
||||
|
||||
console.log(`[Migration] ${oldFolderName} -> ${newFolderName}`);
|
||||
}
|
||||
|
||||
// 3. 保存迁移后的 metainfo
|
||||
await db.setGlobalValue('video.metainfo', JSON.stringify(metaInfo));
|
||||
console.log('[OpenList Migration] MetaInfo 迁移完成');
|
||||
}
|
||||
|
||||
// 4. 更新配置:RootPath -> RootPaths
|
||||
const config = await getConfig();
|
||||
config.OpenListConfig!.RootPaths = [oldRootPath];
|
||||
delete config.OpenListConfig!.RootPath;
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
console.log('[OpenList Migration] 配置迁移完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 OpenList 刷新任务
|
||||
@@ -45,13 +104,24 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
||||
throw new Error('TMDB API Key 未配置');
|
||||
}
|
||||
|
||||
// 检测是否需要迁移
|
||||
if (openListConfig.RootPath && !openListConfig.RootPaths) {
|
||||
await migrateToMultiRoot(openListConfig);
|
||||
// 重新加载配置
|
||||
const newConfig = await getConfig();
|
||||
Object.assign(openListConfig, newConfig.OpenListConfig);
|
||||
}
|
||||
|
||||
cleanupOldTasks();
|
||||
const taskId = createScanTask();
|
||||
|
||||
performScan(
|
||||
const rootPaths = getRootPaths(openListConfig);
|
||||
|
||||
// 顺序扫描多个根目录
|
||||
performMultiRootScan(
|
||||
taskId,
|
||||
openListConfig.URL,
|
||||
openListConfig.RootPath || '/',
|
||||
rootPaths,
|
||||
tmdbApiKey,
|
||||
tmdbProxy,
|
||||
openListConfig.Username,
|
||||
@@ -66,6 +136,43 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描多个根目录
|
||||
*/
|
||||
async function performMultiRootScan(
|
||||
taskId: string,
|
||||
url: string,
|
||||
rootPaths: string[],
|
||||
tmdbApiKey: string,
|
||||
tmdbProxy: string | undefined,
|
||||
username: string,
|
||||
password: string,
|
||||
clearMetaInfo: boolean,
|
||||
scanMode: 'torrent' | 'name' | 'hybrid'
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < rootPaths.length; i++) {
|
||||
const rootPath = rootPaths[i];
|
||||
console.log(`[OpenList Refresh] 扫描根目录 (${i + 1}/${rootPaths.length}): ${rootPath}`);
|
||||
|
||||
try {
|
||||
await performScan(
|
||||
taskId,
|
||||
url,
|
||||
rootPath,
|
||||
tmdbApiKey,
|
||||
tmdbProxy,
|
||||
username,
|
||||
password,
|
||||
clearMetaInfo && i === 0, // 只在第一个根目录时清除
|
||||
scanMode
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[OpenList Refresh] 根目录 ${rootPath} 扫描失败:`, error);
|
||||
// 继续扫描其他根目录
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行扫描任务
|
||||
*/
|
||||
@@ -112,7 +219,7 @@ async function performScan(
|
||||
}
|
||||
}
|
||||
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
invalidateMetaInfoCache();
|
||||
|
||||
const folders: any[] = [];
|
||||
let currentPage = 1;
|
||||
@@ -155,12 +262,15 @@ async function performScan(
|
||||
|
||||
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
||||
|
||||
if (!clearMetaInfo && folderNameToKey.has(folder.name)) {
|
||||
// folderName 存储完整路径(包含根目录)
|
||||
const fullFolderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder.name}`;
|
||||
|
||||
if (!clearMetaInfo && folderNameToKey.has(fullFolderPath)) {
|
||||
existingCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderKey = generateFolderKey(folder.name, existingKeys);
|
||||
const folderKey = generateFolderKey(fullFolderPath, existingKeys);
|
||||
existingKeys.add(folderKey);
|
||||
|
||||
try {
|
||||
@@ -197,7 +307,7 @@ async function performScan(
|
||||
const result = searchResult.result;
|
||||
|
||||
const folderInfo: any = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: result.id,
|
||||
title: result.title || result.name || folder.name,
|
||||
poster_path: result.poster_path,
|
||||
@@ -249,7 +359,7 @@ async function performScan(
|
||||
newCount++;
|
||||
} else {
|
||||
metaInfo.folders[folderKey] = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
@@ -267,7 +377,7 @@ async function performScan(
|
||||
} catch (error) {
|
||||
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||
metaInfo.folders[folderKey] = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
@@ -287,8 +397,8 @@ async function performScan(
|
||||
const metainfoContent = JSON.stringify(metaInfo);
|
||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
invalidateMetaInfoCache();
|
||||
setCachedMetaInfo(metaInfo);
|
||||
|
||||
const config = await getConfig();
|
||||
config.OpenListConfig!.LastRefreshTime = Date.now();
|
||||
|
||||
@@ -280,6 +280,31 @@ export class OpenListClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取视频预览流
|
||||
async getVideoPreview(path: string): Promise<any> {
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/other`, {
|
||||
method: 'POST',
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path: path,
|
||||
method: 'video_preview',
|
||||
password: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`视频预览请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 200) {
|
||||
throw new Error(`视频预览失败: ${data.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 检查连通性
|
||||
async checkConnectivity(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
|
||||
@@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
playrecord_migrated?: boolean;
|
||||
favorite_migrated?: boolean;
|
||||
skip_migrated?: boolean;
|
||||
last_movie_request_time?: number;
|
||||
} | null> {
|
||||
const userInfo = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.userInfoKey(userName))
|
||||
@@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
playrecord_migrated: userInfo.playrecord_migrated === 'true',
|
||||
favorite_migrated: userInfo.favorite_migrated === 'true',
|
||||
skip_migrated: userInfo.skip_migrated === 'true',
|
||||
last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1240,4 +1242,52 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 求片相关 ----------
|
||||
private movieRequestsKey() {
|
||||
return 'movie_requests:all';
|
||||
}
|
||||
|
||||
private userMovieRequestsKey(userName: string) {
|
||||
return `u:${userName}:mr`;
|
||||
}
|
||||
|
||||
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
|
||||
const data = await this.withRetry(() => this.client.hGetAll(this.movieRequestsKey()));
|
||||
if (!data || Object.keys(data).length === 0) return [];
|
||||
return Object.values(data).map(v => JSON.parse(v) as import('./types').MovieRequest);
|
||||
}
|
||||
|
||||
async getMovieRequest(requestId: string): Promise<import('./types').MovieRequest | null> {
|
||||
const val = await this.withRetry(() => this.client.hGet(this.movieRequestsKey(), requestId));
|
||||
return val ? (JSON.parse(val) as import('./types').MovieRequest) : null;
|
||||
}
|
||||
|
||||
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> {
|
||||
await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request)));
|
||||
}
|
||||
|
||||
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||
const existing = await this.getMovieRequest(requestId);
|
||||
if (!existing) throw new Error('Movie request not found');
|
||||
const updated = { ...existing, ...updates };
|
||||
await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated)));
|
||||
}
|
||||
|
||||
async deleteMovieRequest(requestId: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.hDel(this.movieRequestsKey(), requestId));
|
||||
}
|
||||
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
const val = await this.withRetry(() => this.client.sMembers(this.userMovieRequestsKey(userName)));
|
||||
return val ? ensureStringArray(val) : [];
|
||||
}
|
||||
|
||||
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.sAdd(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
|
||||
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
}
|
||||
|
||||
+51
-1
@@ -117,6 +117,30 @@ export interface IStorage {
|
||||
// 收藏更新检查相关
|
||||
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
||||
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
||||
|
||||
// 求片相关
|
||||
getAllMovieRequests(): Promise<MovieRequest[]>;
|
||||
getMovieRequest(requestId: string): Promise<MovieRequest | null>;
|
||||
createMovieRequest(request: MovieRequest): Promise<void>;
|
||||
updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void>;
|
||||
deleteMovieRequest(requestId: string): Promise<void>;
|
||||
getUserMovieRequests(userName: string): Promise<string[]>;
|
||||
addUserMovieRequest(userName: string, requestId: string): Promise<void>;
|
||||
removeUserMovieRequest(userName: string, requestId: string): Promise<void>;
|
||||
|
||||
// 新版用户存储(V2)- 可选方法
|
||||
getUserInfoV2?(userName: string): Promise<{
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
playrecord_migrated?: boolean;
|
||||
favorite_migrated?: boolean;
|
||||
skip_migrated?: boolean;
|
||||
last_movie_request_time?: number;
|
||||
} | null>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
@@ -137,6 +161,10 @@ export interface SearchResult {
|
||||
vod_total?: number; // 总集数
|
||||
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
||||
subtitles?: Array<Array<{ label: string; url: string }>>; // 字幕列表(按集数索引)
|
||||
tmdb_id?: number; // TMDB ID
|
||||
rating?: number; // 评分
|
||||
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
||||
metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName)
|
||||
}
|
||||
|
||||
// 豆瓣数据结构
|
||||
@@ -191,7 +219,9 @@ export interface EpisodeFilterConfig {
|
||||
export type NotificationType =
|
||||
| 'favorite_update' // 收藏更新
|
||||
| 'system' // 系统通知
|
||||
| 'announcement'; // 公告
|
||||
| 'announcement' // 公告
|
||||
| 'movie_request' // 新求片通知(给管理员)
|
||||
| 'request_fulfilled'; // 求片已上架通知(给求片用户)
|
||||
|
||||
// 通知数据结构
|
||||
export interface Notification {
|
||||
@@ -215,3 +245,23 @@ export interface FavoriteUpdateCheck {
|
||||
new_episodes: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 求片请求数据结构
|
||||
export interface MovieRequest {
|
||||
id: string;
|
||||
tmdbId?: number;
|
||||
title: string;
|
||||
year?: string;
|
||||
mediaType: 'movie' | 'tv';
|
||||
season?: number; // 季度(仅剧集)
|
||||
poster?: string;
|
||||
overview?: string;
|
||||
requestedBy: string[];
|
||||
requestCount: number;
|
||||
status: 'pending' | 'fulfilled';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
fulfilledAt?: number;
|
||||
fulfilledSource?: string;
|
||||
fulfilledId?: string;
|
||||
}
|
||||
|
||||
+165
-90
@@ -58,10 +58,31 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
export class UpstashRedisStorage implements IStorage {
|
||||
private client: Redis;
|
||||
private _client: Redis;
|
||||
client: any;
|
||||
|
||||
constructor() {
|
||||
this.client = getUpstashRedisClient();
|
||||
this._client = getUpstashRedisClient();
|
||||
// 创建兼容Redis API的client包装器(支持camelCase和lowercase)
|
||||
this.client = {
|
||||
hSet: (key: string, field: string | Record<string, any>, value?: string) => {
|
||||
if (typeof field === 'string' && value !== undefined) {
|
||||
return this._client.hset(key, { [field]: value });
|
||||
}
|
||||
return this._client.hset(key, field as Record<string, any>);
|
||||
},
|
||||
hset: (key: string, data: Record<string, any>) => this._client.hset(key, data),
|
||||
zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
|
||||
zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
|
||||
set: (key: string, value: string) => this._client.set(key, value),
|
||||
hGetAll: (key: string) => this._client.hgetall(key),
|
||||
hgetall: (key: string) => this._client.hgetall(key),
|
||||
};
|
||||
}
|
||||
|
||||
// 公开withRetry方法供外部使用
|
||||
withRetry<T>(operation: () => Promise<T>, maxRetries = 3): Promise<T> {
|
||||
return withRetry(operation, maxRetries);
|
||||
}
|
||||
|
||||
// ---------- 播放记录 ----------
|
||||
@@ -79,7 +100,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.prHashKey(userName), key)
|
||||
this._client.hget(this.prHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as PlayRecord) : null;
|
||||
}
|
||||
@@ -89,14 +110,14 @@ export class UpstashRedisStorage implements IStorage {
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() => this.client.hset(this.prHashKey(userName), { [key]: record }));
|
||||
await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record }));
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall(this.prHashKey(userName))
|
||||
this._client.hgetall(this.prHashKey(userName))
|
||||
);
|
||||
|
||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||
@@ -111,7 +132,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.hdel(this.prHashKey(userName), key));
|
||||
await withRetry(() => this._client.hdel(this.prHashKey(userName), key));
|
||||
}
|
||||
|
||||
// 清理超出限制的旧播放记录
|
||||
@@ -210,13 +231,13 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 2. 获取旧结构的所有播放记录key
|
||||
const pattern = `u:${userName}:pr:*`;
|
||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||
|
||||
if (oldKeys.length === 0) {
|
||||
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
|
||||
// 即使没有数据也标记为已迁移
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
);
|
||||
// 清除用户信息缓存
|
||||
userInfoCache?.delete(userName);
|
||||
@@ -228,7 +249,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 3. 批量获取旧数据并转换为hash格式
|
||||
const hashData: Record<string, any> = {};
|
||||
for (const fullKey of oldKeys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
const value = await withRetry(() => this._client.get(fullKey));
|
||||
if (value) {
|
||||
// 提取 source+id 部分作为hash的field
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
@@ -239,18 +260,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 4. 写入新的hash结构
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.prHashKey(userName), hashData)
|
||||
this._client.hset(this.prHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`);
|
||||
}
|
||||
|
||||
// 5. 删除旧的key
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`);
|
||||
|
||||
// 6. 标记迁移完成
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
);
|
||||
|
||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||
@@ -271,7 +292,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.favHashKey(userName), key)
|
||||
this._client.hget(this.favHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as Favorite) : null;
|
||||
}
|
||||
@@ -281,12 +302,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
await withRetry(() => this.client.hset(this.favHashKey(userName), { [key]: favorite }));
|
||||
await withRetry(() => this._client.hset(this.favHashKey(userName), { [key]: favorite }));
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall(this.favHashKey(userName))
|
||||
this._client.hgetall(this.favHashKey(userName))
|
||||
);
|
||||
|
||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||
@@ -301,7 +322,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.hdel(this.favHashKey(userName), key));
|
||||
await withRetry(() => this._client.hdel(this.favHashKey(userName), key));
|
||||
}
|
||||
|
||||
// 迁移收藏:从旧的多key结构迁移到新的hash结构
|
||||
@@ -339,13 +360,13 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 2. 获取旧结构的所有收藏key
|
||||
const pattern = `u:${userName}:fav:*`;
|
||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||
|
||||
if (oldKeys.length === 0) {
|
||||
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
|
||||
// 即使没有数据也标记为已迁移
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
);
|
||||
// 清除用户信息缓存
|
||||
userInfoCache?.delete(userName);
|
||||
@@ -357,7 +378,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 3. 批量获取旧数据并转换为hash格式
|
||||
const hashData: Record<string, any> = {};
|
||||
for (const fullKey of oldKeys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
const value = await withRetry(() => this._client.get(fullKey));
|
||||
if (value) {
|
||||
// 提取 source+id 部分作为hash的field
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
@@ -368,18 +389,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 4. 写入新的hash结构
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.favHashKey(userName), hashData)
|
||||
this._client.hset(this.favHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`);
|
||||
}
|
||||
|
||||
// 5. 删除旧的key
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的收藏key`);
|
||||
|
||||
// 6. 标记迁移完成
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
);
|
||||
|
||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||
@@ -395,7 +416,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
this._client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
@@ -406,7 +427,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
this._client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
@@ -415,52 +436,52 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
this._client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||
await withRetry(() => this._client.del(this.userPwdKey(userName)));
|
||||
|
||||
// 删除搜索历史
|
||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||
await withRetry(() => this._client.del(this.shKey(userName)));
|
||||
|
||||
// 删除播放记录(新hash结构)
|
||||
await withRetry(() => this.client.del(this.prHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.prHashKey(userName)));
|
||||
|
||||
// 删除旧的播放记录key(如果有)
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
this._client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...playRecordKeys));
|
||||
await withRetry(() => this._client.del(...playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹(新hash结构)
|
||||
await withRetry(() => this.client.del(this.favHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.favHashKey(userName)));
|
||||
|
||||
// 删除旧的收藏key(如果有)
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
this._client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...favoriteKeys));
|
||||
await withRetry(() => this._client.del(...favoriteKeys));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置(新hash结构)
|
||||
await withRetry(() => this.client.del(this.skipHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.skipHashKey(userName)));
|
||||
|
||||
// 删除旧的跳过配置key(如果有)
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
this._client.keys(skipConfigPattern)
|
||||
);
|
||||
if (skipConfigKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...skipConfigKeys));
|
||||
await withRetry(() => this._client.del(...skipConfigKeys));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +518,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
): Promise<void> {
|
||||
// 先检查用户是否已存在(原子性检查)
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
this._client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
if (exists === 1) {
|
||||
throw new Error('用户已存在');
|
||||
@@ -521,17 +542,17 @@ export class UpstashRedisStorage implements IStorage {
|
||||
if (oidcSub) {
|
||||
userInfo.oidcSub = oidcSub;
|
||||
// 创建OIDC映射
|
||||
await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||
await withRetry(() => this._client.set(this.oidcSubKey(oidcSub), userName));
|
||||
}
|
||||
|
||||
if (enabledApis && enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(enabledApis);
|
||||
}
|
||||
|
||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||
await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo));
|
||||
|
||||
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||
await withRetry(() => this.client.zadd(this.userListKey(), {
|
||||
await withRetry(() => this._client.zadd(this.userListKey(), {
|
||||
score: createdAt,
|
||||
member: userName,
|
||||
}));
|
||||
@@ -546,7 +567,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 验证用户密码(新版本)
|
||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
this._client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || !userInfo.password) {
|
||||
@@ -568,6 +589,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
playrecord_migrated?: boolean;
|
||||
favorite_migrated?: boolean;
|
||||
skip_migrated?: boolean;
|
||||
last_movie_request_time?: number;
|
||||
} | null> {
|
||||
// 先从缓存获取
|
||||
const cached = userInfoCache?.get(userName);
|
||||
@@ -576,7 +598,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
this._client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||
@@ -661,6 +683,11 @@ export class UpstashRedisStorage implements IStorage {
|
||||
playrecord_migrated,
|
||||
favorite_migrated,
|
||||
skip_migrated,
|
||||
last_movie_request_time: userInfo.last_movie_request_time
|
||||
? (typeof userInfo.last_movie_request_time === 'number'
|
||||
? userInfo.last_movie_request_time
|
||||
: parseInt(userInfo.last_movie_request_time as string, 10))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 存入缓存
|
||||
@@ -696,7 +723,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
userInfo.tags = JSON.stringify(updates.tags);
|
||||
} else {
|
||||
// 删除tags字段
|
||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'tags'));
|
||||
await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'tags'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +732,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
||||
} else {
|
||||
// 删除enabledApis字段
|
||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
||||
await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,15 +740,15 @@ export class UpstashRedisStorage implements IStorage {
|
||||
const oldInfo = await this.getUserInfoV2(userName);
|
||||
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||
// 删除旧的OIDC映射
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
await withRetry(() => this._client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
}
|
||||
userInfo.oidcSub = updates.oidcSub;
|
||||
// 创建新的OIDC映射
|
||||
await withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||
await withRetry(() => this._client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||
}
|
||||
|
||||
if (Object.keys(userInfo).length > 0) {
|
||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||
await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo));
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
@@ -732,7 +759,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||
const hashedPassword = await this.hashPassword(newPassword);
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||
this._client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||
);
|
||||
|
||||
// 清除缓存
|
||||
@@ -742,7 +769,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 检查用户是否存在(新版本)
|
||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
this._client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
@@ -750,7 +777,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 通过OIDC Sub查找用户名
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
const userName = await withRetry(() =>
|
||||
this.client.get(this.oidcSubKey(oidcSub))
|
||||
this._client.get(this.oidcSubKey(oidcSub))
|
||||
);
|
||||
return userName ? ensureString(userName) : null;
|
||||
}
|
||||
@@ -763,7 +790,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
let cursor: number | string = 0;
|
||||
do {
|
||||
const result = await withRetry(() =>
|
||||
this.client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
||||
this._client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
||||
);
|
||||
|
||||
cursor = result[0];
|
||||
@@ -771,7 +798,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 检查每个用户的 tags
|
||||
for (const key of keys) {
|
||||
const userInfo = await withRetry(() => this.client.hgetall(key));
|
||||
const userInfo = await withRetry(() => this._client.hgetall(key));
|
||||
if (userInfo && userInfo.tags) {
|
||||
const tags = JSON.parse(userInfo.tags as string);
|
||||
if (tags.includes(tagName)) {
|
||||
@@ -804,7 +831,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
total: number;
|
||||
}> {
|
||||
// 获取总数
|
||||
let total = await withRetry(() => this.client.zcard(this.userListKey()));
|
||||
let total = await withRetry(() => this._client.zcard(this.userListKey()));
|
||||
|
||||
// 检查站长是否在数据库中(使用缓存)
|
||||
let ownerInfo = null;
|
||||
@@ -851,7 +878,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 获取用户列表(按注册时间升序)
|
||||
const usernames = await withRetry(() =>
|
||||
this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||
this._client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||
);
|
||||
|
||||
const users = [];
|
||||
@@ -902,14 +929,14 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 删除OIDC映射
|
||||
if (userInfo?.oidcSub) {
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
await withRetry(() => this._client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
}
|
||||
|
||||
// 删除用户信息Hash
|
||||
await withRetry(() => this.client.del(this.userInfoKey(userName)));
|
||||
await withRetry(() => this._client.del(this.userInfoKey(userName)));
|
||||
|
||||
// 从用户列表中移除
|
||||
await withRetry(() => this.client.zrem(this.userListKey(), userName));
|
||||
await withRetry(() => this._client.zrem(this.userListKey(), userName));
|
||||
|
||||
// 删除用户的其他数据(播放记录、收藏等)
|
||||
await this.deleteUser(userName);
|
||||
@@ -925,7 +952,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this.client.lrange(this.shKey(userName), 0, -1)
|
||||
this._client.lrange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
@@ -934,19 +961,19 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
// 先去重
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
await withRetry(() => this._client.lrem(key, 0, ensureString(keyword)));
|
||||
// 插入到最前
|
||||
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
|
||||
await withRetry(() => this._client.lpush(key, ensureString(keyword)));
|
||||
// 限制最大长度
|
||||
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
await withRetry(() => this._client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
await withRetry(() => this._client.lrem(key, 0, ensureString(keyword)));
|
||||
} else {
|
||||
await withRetry(() => this.client.del(key));
|
||||
await withRetry(() => this._client.del(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,7 +982,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 从新版用户列表获取
|
||||
const userListKey = this.userListKey();
|
||||
const users = await withRetry(() =>
|
||||
this.client.zrange(userListKey, 0, -1)
|
||||
this._client.zrange(userListKey, 0, -1)
|
||||
);
|
||||
const userList = users.map(u => ensureString(u));
|
||||
|
||||
@@ -974,12 +1001,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||
const val = await withRetry(() => this._client.get(this.adminConfigKey()));
|
||||
return val ? (val as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||
await withRetry(() => this._client.set(this.adminConfigKey(), config));
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
@@ -998,7 +1025,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
): Promise<SkipConfig | null> {
|
||||
const key = `${source}+${id}`;
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.skipHashKey(userName), key)
|
||||
this._client.hget(this.skipHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as SkipConfig) : null;
|
||||
}
|
||||
@@ -1011,7 +1038,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
): Promise<void> {
|
||||
const key = `${source}+${id}`;
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.skipHashKey(userName), { [key]: config })
|
||||
this._client.hset(this.skipHashKey(userName), { [key]: config })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1022,7 +1049,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
): Promise<void> {
|
||||
const key = `${source}+${id}`;
|
||||
await withRetry(() =>
|
||||
this.client.hdel(this.skipHashKey(userName), key)
|
||||
this._client.hdel(this.skipHashKey(userName), key)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1030,7 +1057,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||
this._client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||
);
|
||||
|
||||
return hashData || {};
|
||||
@@ -1065,18 +1092,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
const pattern = `u:${userName}:skip:*`;
|
||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||
|
||||
if (oldKeys.length === 0) {
|
||||
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
);
|
||||
userInfoCache?.delete(userName);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await withRetry(() => this.client.mget(oldKeys));
|
||||
const values = await withRetry(() => this._client.mget(oldKeys));
|
||||
|
||||
const hashData: Record<string, SkipConfig> = {};
|
||||
oldKeys.forEach((key, index) => {
|
||||
@@ -1092,16 +1119,16 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.skipHashKey(userName), hashData)
|
||||
this._client.hset(this.skipHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`);
|
||||
}
|
||||
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`);
|
||||
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
);
|
||||
userInfoCache?.delete(userName);
|
||||
|
||||
@@ -1113,7 +1140,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
userName: string
|
||||
): Promise<import('./types').DanmakuFilterConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.danmakuFilterConfigKey(userName))
|
||||
this._client.get(this.danmakuFilterConfigKey(userName))
|
||||
);
|
||||
return val ? (val as import('./types').DanmakuFilterConfig) : null;
|
||||
}
|
||||
@@ -1123,13 +1150,13 @@ export class UpstashRedisStorage implements IStorage {
|
||||
config: import('./types').DanmakuFilterConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.danmakuFilterConfigKey(userName), config)
|
||||
this._client.set(this.danmakuFilterConfigKey(userName), config)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteDanmakuFilterConfig(userName: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.del(this.danmakuFilterConfigKey(userName))
|
||||
this._client.del(this.danmakuFilterConfigKey(userName))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1145,7 +1172,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
// 删除管理员配置
|
||||
await withRetry(() => this.client.del(this.adminConfigKey()));
|
||||
await withRetry(() => this._client.del(this.adminConfigKey()));
|
||||
|
||||
console.log('所有数据已清空');
|
||||
} catch (error) {
|
||||
@@ -1161,7 +1188,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getGlobalValue(key: string): Promise<string | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.globalValueKey(key))
|
||||
this._client.get(this.globalValueKey(key))
|
||||
);
|
||||
// Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串
|
||||
if (val === null) return null;
|
||||
@@ -1172,12 +1199,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.globalValueKey(key), value)
|
||||
this._client.set(this.globalValueKey(key), value)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.globalValueKey(key)));
|
||||
await withRetry(() => this._client.del(this.globalValueKey(key)));
|
||||
}
|
||||
|
||||
// ---------- 通知相关 ----------
|
||||
@@ -1191,7 +1218,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.notificationsKey(userName))
|
||||
this._client.get(this.notificationsKey(userName))
|
||||
);
|
||||
return val ? (val as import('./types').Notification[]) : [];
|
||||
}
|
||||
@@ -1207,7 +1234,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
notifications.splice(100);
|
||||
}
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), notifications)
|
||||
this._client.set(this.notificationsKey(userName), notifications)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1220,7 +1247,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), notifications)
|
||||
this._client.set(this.notificationsKey(userName), notifications)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1232,12 +1259,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
const filtered = notifications.filter((n) => n.id !== notificationId);
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), filtered)
|
||||
this._client.set(this.notificationsKey(userName), filtered)
|
||||
);
|
||||
}
|
||||
|
||||
async clearAllNotifications(userName: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.notificationsKey(userName)));
|
||||
await withRetry(() => this._client.del(this.notificationsKey(userName)));
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
@@ -1247,7 +1274,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.lastFavoriteCheckKey(userName))
|
||||
this._client.get(this.lastFavoriteCheckKey(userName))
|
||||
);
|
||||
return val ? (val as number) : 0;
|
||||
}
|
||||
@@ -1257,9 +1284,57 @@ export class UpstashRedisStorage implements IStorage {
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp)
|
||||
this._client.set(this.lastFavoriteCheckKey(userName), timestamp)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 求片相关 ----------
|
||||
private movieRequestsKey() {
|
||||
return 'movie_requests:all';
|
||||
}
|
||||
|
||||
private userMovieRequestsKey(userName: string) {
|
||||
return `u:${userName}:mr`;
|
||||
}
|
||||
|
||||
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
|
||||
const data = await withRetry(() => this._client.hgetall(this.movieRequestsKey()));
|
||||
if (!data) return [];
|
||||
return Object.values(data) as import('./types').MovieRequest[];
|
||||
}
|
||||
|
||||
async getMovieRequest(requestId: string): Promise<import('./types').MovieRequest | null> {
|
||||
const val = await withRetry(() => this._client.hget(this.movieRequestsKey(), requestId));
|
||||
return val ? (val as import('./types').MovieRequest) : null;
|
||||
}
|
||||
|
||||
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> {
|
||||
await withRetry(() => this._client.hset(this.movieRequestsKey(), { [request.id]: request }));
|
||||
}
|
||||
|
||||
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||
const existing = await this.getMovieRequest(requestId);
|
||||
if (!existing) throw new Error('Movie request not found');
|
||||
const updated = { ...existing, ...updates };
|
||||
await withRetry(() => this._client.hset(this.movieRequestsKey(), { [requestId]: updated }));
|
||||
}
|
||||
|
||||
async deleteMovieRequest(requestId: string): Promise<void> {
|
||||
await withRetry(() => this._client.hdel(this.movieRequestsKey(), requestId));
|
||||
}
|
||||
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
const val = await withRetry(() => this._client.smembers(this.userMovieRequestsKey(userName)));
|
||||
return val ? ensureStringArray(val) : [];
|
||||
}
|
||||
|
||||
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await withRetry(() => this._client.sadd(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
|
||||
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId));
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
|
||||
+71
-1
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
import he from 'he';
|
||||
import Hls from 'hls.js';
|
||||
import bs58 from 'bs58';
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType:
|
||||
@@ -135,12 +136,13 @@ export function processVideoUrl(originalUrl: string): string {
|
||||
/**
|
||||
* 从m3u8地址获取视频质量等级和网络信息
|
||||
* @param m3u8Url m3u8播放列表的URL
|
||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number, bitrate: string}> 视频质量等级和网络信息
|
||||
*/
|
||||
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
quality: string; // 如720p、1080p等
|
||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||
pingTime: number; // 网络延迟(毫秒)
|
||||
bitrate: string; // 视频码率(如 "2.5 Mbps")
|
||||
}> {
|
||||
try {
|
||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||
@@ -182,6 +184,7 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||
|
||||
let fragmentStartTime = 0;
|
||||
|
||||
@@ -211,17 +214,32 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
? '480p'
|
||||
: 'SD'; // 480p: 854x480
|
||||
|
||||
// 格式化码率
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
} else {
|
||||
// webkit 无法获取尺寸,直接返回
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality: '未知',
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -255,6 +273,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||
}
|
||||
hasSpeedCalculated = true;
|
||||
|
||||
// 从分片估算码率
|
||||
if (data.frag && data.frag.duration > 0) {
|
||||
const fragmentDuration = data.frag.duration; // 分片时长(秒)
|
||||
const fragmentSize = size; // 分片大小(字节)
|
||||
|
||||
// 码率 = (分片大小 × 8 bits) / 分片时长
|
||||
estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration);
|
||||
|
||||
console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
checkAndResolve(); // 尝试返回结果
|
||||
}
|
||||
}
|
||||
@@ -301,3 +331,43 @@ export function cleanHtmlTags(text: string): string {
|
||||
// 使用 he 库解码 HTML 实体
|
||||
return he.decode(cleanedText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串编码为 Base58
|
||||
* @param str 要编码的字符串
|
||||
* @returns Base58 编码后的字符串
|
||||
*/
|
||||
export function base58Encode(str: string): string {
|
||||
if (!str) return '';
|
||||
|
||||
// 在浏览器环境中使用 TextEncoder
|
||||
if (typeof window !== 'undefined') {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(str);
|
||||
return bs58.encode(bytes);
|
||||
}
|
||||
|
||||
// 在 Node.js 环境中使用 Buffer
|
||||
const buffer = Buffer.from(str, 'utf-8');
|
||||
return bs58.encode(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base58 字符串解码为原始字符串
|
||||
* @param encoded Base58 编码的字符串
|
||||
* @returns 解码后的原始字符串
|
||||
*/
|
||||
export function base58Decode(encoded: string): string {
|
||||
if (!encoded) return '';
|
||||
|
||||
const bytes = bs58.decode(encoded);
|
||||
|
||||
// 在浏览器环境中使用 TextDecoder
|
||||
if (typeof window !== 'undefined') {
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
// 在 Node.js 环境中使用 Buffer
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
+17
-7
@@ -28,11 +28,11 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||
|
||||
// 降级方案:使用多种正则模式提取集数
|
||||
// 按优先级排序:更具体的模式优先
|
||||
const patterns: Array<{ pattern: RegExp; isOVA?: boolean }> = [
|
||||
const patterns: Array<{ pattern: RegExp; isOVA?: boolean; extractSeason?: boolean }> = [
|
||||
// OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先
|
||||
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
||||
{ pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ },
|
||||
// S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体
|
||||
{ pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true },
|
||||
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
||||
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||
@@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
||||
];
|
||||
|
||||
for (const { pattern, isOVA } of patterns) {
|
||||
for (const { pattern, isOVA, extractSeason } of patterns) {
|
||||
const match = fileName.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const episode = parseFloat(match[1]);
|
||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
||||
return { episode, isOVA };
|
||||
if (extractSeason && match[2]) {
|
||||
// 同时提取 season 和 episode
|
||||
const season = parseInt(match[1]);
|
||||
const episode = parseFloat(match[2]);
|
||||
if (season > 0 && season < 100 && episode > 0 && episode < 10000) {
|
||||
return { season, episode };
|
||||
}
|
||||
} else {
|
||||
// 只提取 episode
|
||||
const episode = parseFloat(match[1]);
|
||||
if (episode > 0 && episode < 10000) {
|
||||
return { episode, isOVA };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/* 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' | 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件夹名提取 TMDb ID 和年份
|
||||
* 格式: "标题 (年份) {tmdb-id}"
|
||||
*/
|
||||
function parseFolderName(folderName: string | undefined): {
|
||||
title?: string;
|
||||
year?: string;
|
||||
tmdbId?: number;
|
||||
} {
|
||||
if (!folderName || typeof folderName !== 'string') {
|
||||
return {};
|
||||
}
|
||||
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 videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||
|
||||
let isInSeasonDir = false;
|
||||
|
||||
if (isFilePath) {
|
||||
// 文件路径:判断父目录是否为季度目录
|
||||
const parentDir = pathParts[pathParts.length - 2];
|
||||
isInSeasonDir = /(season\s*\d+|s\d+)/i.test(parentDir);
|
||||
} else {
|
||||
// 目录路径:判断当前目录是否为季度目录
|
||||
const currentDir = pathParts[pathParts.length - 1];
|
||||
isInSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDir);
|
||||
}
|
||||
|
||||
const nfoSearchPaths: string[] = [];
|
||||
|
||||
if (isInSeasonDir) {
|
||||
// 电视剧:查父级的 tvshow.nfo
|
||||
const grandParentDir = pathParts.slice(0, isFilePath ? -2 : -1).join('/');
|
||||
nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`);
|
||||
} else {
|
||||
// 电影:查同级的 movie.nfo
|
||||
const parentDir = pathParts.slice(0, isFilePath ? -1 : pathParts.length).join('/');
|
||||
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);
|
||||
|
||||
// 验证路径格式
|
||||
if (pathParts.length < 1) {
|
||||
throw new Error(`无效的视频路径格式: ${videoPath}`);
|
||||
}
|
||||
|
||||
// 判断是否为文件路径(包含视频扩展名)
|
||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||
|
||||
// 如果是文件路径,检查是否在季度目录中
|
||||
const isInSeasonDir = isFilePath && pathParts.length >= 2 && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]);
|
||||
|
||||
// 验证路径长度是否足够
|
||||
if (isInSeasonDir && pathParts.length < 3) {
|
||||
throw new Error(`Season目录路径格式不正确: ${videoPath}`);
|
||||
}
|
||||
|
||||
// 确定元数据目录和文件夹名
|
||||
let metadataDir: string;
|
||||
let folderName: string;
|
||||
|
||||
if (isFilePath) {
|
||||
// 文件路径
|
||||
metadataDir = isInSeasonDir
|
||||
? pathParts.slice(0, -2).join('/')
|
||||
: pathParts.slice(0, -1).join('/');
|
||||
folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2];
|
||||
} else {
|
||||
// 目录路径
|
||||
if (pathParts.length === 1) {
|
||||
// 只有一级目录
|
||||
metadataDir = '';
|
||||
folderName = pathParts[0];
|
||||
} else {
|
||||
// 判断当前目录是否为季度目录
|
||||
const currentDirName = pathParts[pathParts.length - 1];
|
||||
const isSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDirName);
|
||||
|
||||
if (isSeasonDir && pathParts.length >= 2) {
|
||||
// 季度目录:使用父级目录名
|
||||
metadataDir = pathParts.slice(0, -2).join('/');
|
||||
folderName = pathParts[pathParts.length - 2];
|
||||
} else {
|
||||
metadataDir = pathParts.slice(0, -1).join('/');
|
||||
folderName = pathParts[pathParts.length - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 folderName 是否有效
|
||||
if (!folderName) {
|
||||
throw new Error(`无法从路径中提取文件夹名: ${videoPath}`);
|
||||
}
|
||||
|
||||
// 优先级 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' : (isFilePath ? 'file' : '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();
|
||||
|
||||
// 如果文件名是纯数字(可能带小数点)或者是 SxxExx 格式,跳过文件名搜索,直接使用文件夹名
|
||||
const isPureNumber = /^[\d.]+$/.test(searchQuery);
|
||||
const isSeasonEpisode = /^S\d+E\d+/i.test(searchQuery);
|
||||
|
||||
if (!isPureNumber && !isSeasonEpisode) {
|
||||
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: isFilePath ? 'file' : 'tmdb',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级 4: 实时搜索 TMDb(使用文件夹名)
|
||||
if (tmdbApiKey) {
|
||||
const searchQuery = folderName
|
||||
.replace(/[\[\](){}]/g, ' ')
|
||||
.replace(/\d{4}/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 videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||
|
||||
// 如果是文件路径,检查是否在季度目录中
|
||||
const isInSeasonDir = isFilePath && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]);
|
||||
|
||||
if (isInSeasonDir) {
|
||||
// 电视剧:列出当前季的所有集
|
||||
const seasonDir = pathParts.slice(0, -1).join('/');
|
||||
const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`);
|
||||
|
||||
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);
|
||||
console.log('[xiaoya-metadata] 解析文件名:', file.name, '结果:', parsed);
|
||||
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 targetDir = isFilePath ? pathParts.slice(0, -1).join('/') : pathParts.join('/');
|
||||
const listResponse = await xiaoyaClient.listDirectory(`/${targetDir}`);
|
||||
|
||||
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 => ({
|
||||
path: `/${targetDir}/${file.name}`,
|
||||
title: file.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 或重新登录
|
||||
*/
|
||||
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