/* eslint-disable @typescript-eslint/no-explicit-any */ import parseTorrentName from 'parse-torrent-name'; import { parseStringPromise } from 'xml2js'; import { getConfig, setCachedConfig } from '@/lib/config'; import { db, getStorage } from '@/lib/db'; import { EmailService } from '@/lib/email.service'; import { OpenListClient } from '@/lib/openlist.client'; import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission']; function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool { return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool) ? tool as AnimeSubscriptionDownloadTool : 'aria2'; } /** * 从标题中提取集数 */ export function extractEpisode(title: string): number | null { const parsed = parseTorrentName(title); if (parsed.episode) { return parsed.episode; } // 备用正则匹配 const patterns = [ /\[(\d+)\]/, // [01] /第(\d+)[集话]/, // 第01集 /EP?(\d+)/i, // EP01, E01 /\s(\d+)\s/, // 空格01空格 ]; for (const pattern of patterns) { const match = title.match(pattern); if (match) { return parseInt(match[1], 10); } } return null; } /** * 检查标题是否匹配过滤条件 */ export function matchesFilter(title: string, filterText: string): boolean { if (!filterText) return true; // 支持多个关键词,用逗号分隔,必须全部匹配 const keywords = filterText.split(',').map((k) => k.trim()).filter(Boolean); return keywords.every((keyword) => title.includes(keyword)); } /** * 搜索 ACG 资源(直接调用搜索逻辑,不通过 HTTP) */ export async function searchACG( keyword: string, source: 'acgrip' | 'mikan' | 'dmhy' ) { const trimmedKeyword = keyword.trim(); let searchUrl: string; switch (source) { case 'mikan': searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`; break; case 'dmhy': searchUrl = `http://share.dmhy.org/topics/rss/rss.xml?keyword=${encodeURIComponent(trimmedKeyword)}`; break; case 'acgrip': default: searchUrl = `https://acg.rip/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`; break; } const response = await fetch(searchUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', }, }); if (!response.ok) { throw new Error(`${source} API 请求失败: ${response.status}`); } const xmlData = await response.text(); const parsed = await parseStringPromise(xmlData); if (!parsed?.rss?.channel?.[0]?.item) { return []; } const items = parsed.rss.channel[0].item; // 统一格式 return items.map((item: any) => { const title = item.title?.[0] || ''; const link = item.link?.[0] || ''; const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`; const pubDate = item.pubDate?.[0] || ''; const torrentUrl = item.enclosure?.[0]?.$?.url || ''; const description = item.description?.[0] || ''; return { title, link, guid, pubDate, torrentUrl, description, }; }); } /** * 添加离线下载任务 */ export async function addOfflineDownload( torrentUrl: string, downloadPath: string ) { const config = await getConfig(); const openlistConfig = config.OpenListConfig; const downloadTool = getAnimeSubscriptionDownloadTool( config.AnimeSubscriptionConfig?.DownloadTool ); if (!openlistConfig?.Enabled) { throw new Error('私人影库功能未启用'); } if ( !openlistConfig.URL || !openlistConfig.Username || !openlistConfig.Password ) { throw new Error('OpenList 配置不完整'); } const client = new OpenListClient( openlistConfig.URL, openlistConfig.Username, openlistConfig.Password ); const token = await (client as any).getToken(); const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`; const response = await fetch(openlistUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: token, }, body: JSON.stringify({ path: downloadPath, urls: [torrentUrl], tool: downloadTool, }), }); const data = await response.json(); if (!response.ok || data.code !== 200) { throw new Error(data.message || '添加离线下载任务失败'); } } /** * 发送追番更新通知和邮件 */ async function sendAnimeUpdateNotifications( subscription: AnimeSubscription, episodes: number[] ) { const config = await getConfig(); const storage = getStorage(); // 获取站长用户名 - 从用户列表中查找 owner 角色 let ownerUsername: string | null = null; try { const allUsers = await db.getAllUsers(); for (const username of allUsers) { const userInfo = await db.getUserInfoV2(username); if (userInfo?.role === 'owner') { ownerUsername = username; break; } } } catch (error) { console.error('[AnimeSubscription] 获取站长用户名失败:', error); } if (!ownerUsername) { console.warn('[AnimeSubscription] 未找到站长用户,跳过通知'); return; } // 准备通知内容 const episodeList = episodes.join('、'); const notificationTitle = `追番更新:${subscription.title}`; const notificationMessage = `您订阅的番剧《${subscription.title}》有新集数更新:第 ${episodeList} 集,已下载到私人影库`; // 需要通知的用户列表(去重) const usersToNotify: string[] = [ownerUsername]; // 如果创建者不是站长,也通知创建者 if (subscription.createdBy && subscription.createdBy !== ownerUsername) { usersToNotify.push(subscription.createdBy); } // 发送站内通知 for (const username of usersToNotify) { try { await storage.addNotification(username, { id: crypto.randomUUID(), type: 'anime_subscription_update', title: notificationTitle, message: notificationMessage, timestamp: Date.now(), read: false, metadata: { subscriptionId: subscription.id, subscriptionTitle: subscription.title, episodes: episodes, }, }); console.log(`[AnimeSubscription] 已发送站内通知给用户: ${username}`); } catch (error) { console.error(`[AnimeSubscription] 发送站内通知失败 (${username}):`, error); } } // 发送邮件通知(如果已启用) const emailConfig = config.EmailConfig; if (!emailConfig?.enabled) { return; } // 获取需要发送邮件的用户邮箱 const emailsToSend: Array<{ username: string; email: string }> = []; for (const username of usersToNotify) { try { const userInfo = await db.getUserInfoV2(username); // 使用可选的 email 字段 const email = (userInfo as any)?.email; if (email) { emailsToSend.push({ username, email }); } } catch (error) { console.error(`[AnimeSubscription] 获取用户邮箱失败 (${username}):`, error); } } // 发送邮件 for (const { username, email } of emailsToSend) { try { const emailHtml = `
您好,${username}!
您订阅的番剧有新集数更新:
新增集数:第 ${episodeList} 集
搜索源:${subscription.source === 'acgrip' ? 'ACG.RIP' : subscription.source === 'mikan' ? '蜜柑' : '动漫花园'}
这些集数已自动添加到 OpenList 离线下载队列。
此邮件由系统自动发送,请勿回复。