增强追番订阅:关键词表达式、字幕组快捷、右键添加与缺集补搜

- 过滤/排除支持 & | (),兼容旧逗号语义
- 字幕组快捷单选填入;同名订阅拒绝重复
- 单集只下一次、缺集按「番名+补零集数」重搜
- VideoCard 管理员可添加追番;PlayRecord 增加 is_anime 及迁移
This commit is contained in:
mtvpls
2026-07-26 14:48:58 +08:00
parent ca798177b9
commit 333c482e19
23 changed files with 1433 additions and 103 deletions
+7 -1
View File
@@ -396,10 +396,16 @@ export interface AdminConfig {
Subscriptions: Array<{
id: string;
title: string;
/** 包含关键词:支持 & | ();无运算符时逗号=AND */
filterText: string;
excludeText?: string; // 排除关键词,逗号分隔;标题包含任一则跳过
/** 排除关键词:支持 & | ();无运算符时逗号=OR */
excludeText?: string;
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
enabled: boolean;
/** 单集只下载一次(默认 false) */
onePerEpisode?: boolean;
/** 缺集重新检索(默认 false) */
refillMissingEpisodes?: boolean;
lastCheckTime: number;
lastEpisode: number;
createdAt: number;
+169
View File
@@ -0,0 +1,169 @@
/**
* 追番订阅快捷建议(字幕组单选)
* - labelchip 只显示组名
* - insert:选中后整段写入过滤关键词(替换,非追加)
* 偏好:简日双语 > 简中;内嵌 > 内封(网页对 MKV/内封不友好)
*/
export interface AnimeFansubPreset {
id: string;
/** chip 显示的短名 */
label: string;
/** 选中后写入 filterText 的完整表达式 */
insert: string;
hint?: string;
}
/** 排除关键词快捷(同样单选替换) */
export interface AnimeExcludePreset {
id: string;
label: string;
insert: string;
hint?: string;
}
/** 字幕组快捷列表:单选,一次只选一个组 */
export const ANIME_FANSUB_PRESETS: AnimeFansubPreset[] = [
{
id: 'miao',
label: '喵萌奶茶屋',
insert: '喵萌奶茶屋&简日双语',
hint: '简日双语优先',
},
{
id: 'kitauji',
label: '北宇治',
insert: '北宇治&简日内嵌',
},
{
id: 'lvcha',
label: '绿茶字幕组',
insert: '绿茶&简日内嵌',
},
{
id: 'boxue',
label: '拨雪寻春',
insert: '拨雪寻春&简日内嵌',
},
{
id: 'sandwich',
label: '三明治摆烂组',
insert: '三明治摆烂组&简日内嵌',
},
{
id: 'sakurato',
label: '桜都',
insert: '桜都&简日内嵌',
},
{
id: 'qianxia',
label: '千夏',
insert: '千夏&简日内嵌',
},
{
id: 'ailian',
label: '爱恋',
insert: '爱恋&简日内嵌',
},
{
id: 'zhushen',
label: '诸神',
insert: '诸神&简中',
},
{
id: 'youha',
label: '悠哈璃羽',
insert: '悠哈璃羽&简中',
},
{
id: 'jiying',
label: '极影',
insert: '极影&简中',
},
{
id: 'wandou',
label: '豌豆',
insert: '豌豆&简体',
hint: '多为简体 MP4',
},
{
id: 'ani',
label: 'ANi',
insert: 'ANi&CHS',
hint: '默认多为繁中,已锁 CHS',
},
{
id: 'skymoon',
label: 'Skymoon',
insert: 'Skymoon&CHS',
},
{
id: 'lilith',
label: 'Lilith-Raws',
insert: 'Lilith-Raws&CHS',
},
{
id: 'lolihouse',
label: 'LoliHouse',
insert: 'LoliHouse&简繁内封',
hint: '多为 MKV 内封,网页不友好',
},
];
export const ANIME_EXCLUDE_PRESETS: AnimeExcludePreset[] = [
{
id: 'preview',
label: '预告/PV',
insert: '先行|预告|PV|CM|特报|预览',
},
{
id: 'raw',
label: '生肉',
insert: '生肉|RAW|raw',
},
{
id: '720',
label: '720p',
insert: '720',
},
];
/**
* 字幕组单选:
* - 点未选中的组 → 整段替换为该 insert
* - 再点同一组 → 清空
*/
export function applyFansubSingleSelect(
current: string,
preset: AnimeFansubPreset
): string {
const cur = (current || '').trim();
const ins = preset.insert.trim();
if (cur === ins) return '';
return ins;
}
/** 排除快捷单选(同上) */
export function applyExcludeSingleSelect(
current: string,
preset: AnimeExcludePreset
): string {
const cur = (current || '').trim();
const ins = preset.insert.trim();
if (cur === ins) return '';
return ins;
}
export function isFansubPresetActive(
current: string,
preset: AnimeFansubPreset
): boolean {
return (current || '').trim() === preset.insert.trim();
}
export function isExcludePresetActive(
current: string,
preset: AnimeExcludePreset
): boolean {
return (current || '').trim() === preset.insert.trim();
}
+313
View File
@@ -0,0 +1,313 @@
/**
* 追番订阅关键词表达式(纯函数,可被客户端安全引用)
*
* 语法(优先级:() > & > |):
* expr := or_expr
* or_expr := and_expr ( '|' and_expr )*
* and_expr := primary ( '&' primary )*
* primary := '(' expr ')' | keyword
*
* 兼容旧数据:字符串中不含 & | ( ) 时
* - mode 'and'filter):逗号 = AND
* - mode 'or'exclude):逗号 = OR
*
* 全角 &|() 会归一化为半角。
*/
export type KeywordExprMode = 'and' | 'or';
/** 判断 CMS / 分类文案是否为动漫(客户端/服务端均可) */
export function isAnimeCategoryText(
...parts: Array<string | undefined | null>
): boolean {
const text = parts.filter(Boolean).join(' ');
if (!text) return false;
return /动画|動漫|动漫|anime|アニメ/i.test(text);
}
type ExprNode =
| { type: 'and'; children: ExprNode[] }
| { type: 'or'; children: ExprNode[] }
| { type: 'kw'; value: string };
const OP_CHARS = new Set(['&', '|', '(', ')']);
/** 是否含有表达式运算符(半角或全角) */
export function hasExprOperators(text: string): boolean {
return /[&|()()]||/.test(text);
}
function normalizeOps(text: string): string {
return text
.replace(//g, '&')
.replace(//g, '|')
.replace(//g, '(')
.replace(//g, ')');
}
function normalizeCommas(text: string): string {
return text.replace(//g, ',');
}
/** 旧式逗号分隔关键词 */
export function parseCommaKeywords(text: string): string[] {
return normalizeCommas(text)
.split(',')
.map((k) => k.trim())
.filter(Boolean);
}
type Token =
| { kind: 'op'; value: '&' | '|' | '(' | ')' }
| { kind: 'kw'; value: string };
function tokenize(input: string): Token[] {
const s = normalizeOps(input);
const tokens: Token[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i];
if (/\s/.test(ch)) {
i += 1;
continue;
}
if (ch === '&' || ch === '|' || ch === '(' || ch === ')') {
tokens.push({ kind: 'op', value: ch });
i += 1;
continue;
}
// 关键词可含空格,直到运算符为止
let j = i;
while (j < s.length && !OP_CHARS.has(s[j])) {
j += 1;
}
const raw = s.slice(i, j).trim();
if (raw) {
tokens.push({ kind: 'kw', value: raw });
}
i = j;
}
return tokens;
}
class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'KeywordExprParseError';
}
}
function parseTokens(tokens: Token[]): ExprNode {
let pos = 0;
const peek = () => tokens[pos];
const consume = () => {
const t = tokens[pos];
pos += 1;
return t;
};
function parseOr(): ExprNode {
const parts: ExprNode[] = [parseAnd()];
while (peek()?.kind === 'op' && peek().value === '|') {
consume();
parts.push(parseAnd());
}
if (parts.length === 1) return parts[0];
return { type: 'or', children: parts };
}
function parseAnd(): ExprNode {
const parts: ExprNode[] = [parsePrimary()];
while (peek()?.kind === 'op' && peek().value === '&') {
consume();
parts.push(parsePrimary());
}
if (parts.length === 1) return parts[0];
return { type: 'and', children: parts };
}
function parsePrimary(): ExprNode {
const t = peek();
if (!t) {
throw new ParseError('表达式不完整');
}
if (t.kind === 'op' && t.value === '(') {
consume();
const inner = parseOr();
const close = consume();
if (!close || close.kind !== 'op' || close.value !== ')') {
throw new ParseError('缺少右括号 )');
}
return inner;
}
if (t.kind === 'kw') {
consume();
return { type: 'kw', value: t.value };
}
throw new ParseError(`意外的符号: ${t.value}`);
}
if (tokens.length === 0) {
throw new ParseError('空表达式');
}
const root = parseOr();
if (pos < tokens.length) {
throw new ParseError('表达式存在多余内容');
}
return root;
}
function evalNode(title: string, node: ExprNode): boolean {
switch (node.type) {
case 'kw':
return title.includes(node.value);
case 'and':
return node.children.every((c) => evalNode(title, c));
case 'or':
return node.children.some((c) => evalNode(title, c));
default:
return false;
}
}
/**
* 解析并匹配关键词表达式。
* @param mode 无运算符时的逗号语义:filter 用 andexclude 用 or
* @returns 匹配结果;非法表达式时 match=false 且带 error
*/
export function matchKeywordExpr(
title: string,
exprText: string | undefined | null,
mode: KeywordExprMode
): { match: boolean; error?: string } {
if (exprText == null || !String(exprText).trim()) {
// filter 空 = 全过;exclude 空 = 不排除
return { match: mode === 'and' };
}
const text = String(exprText).trim();
try {
if (!hasExprOperators(text)) {
const keywords = parseCommaKeywords(text);
if (keywords.length === 0) {
return { match: mode === 'and' };
}
if (mode === 'and') {
return { match: keywords.every((k) => title.includes(k)) };
}
return { match: keywords.some((k) => title.includes(k)) };
}
const tokens = tokenize(text);
if (tokens.length === 0) {
return { match: mode === 'and' };
}
const ast = parseTokens(tokens);
return { match: evalNode(title, ast) };
} catch (e) {
const message = e instanceof Error ? e.message : '表达式解析失败';
return { match: false, error: message };
}
}
/** 包含关键词(filter):空=通过;非法表达式=不通过 */
export function matchesFilter(title: string, filterText: string): boolean {
if (!filterText) return true;
const result = matchKeywordExpr(title, filterText, 'and');
if (result.error) {
console.warn(`[AnimeSubscription] 过滤表达式无效: ${result.error} | ${filterText}`);
}
return result.match;
}
/** 排除关键词(exclude):空=不排除;命中=true 表示应跳过 */
export function matchesExclude(title: string, excludeText?: string): boolean {
if (!excludeText) return false;
const result = matchKeywordExpr(title, excludeText, 'or');
if (result.error) {
console.warn(`[AnimeSubscription] 排除表达式无效: ${result.error} | ${excludeText}`);
// 非法排除式:保守起见不排除(避免误杀全部),但已打日志
return false;
}
return result.match;
}
/** 校验表达式是否可解析(供 API/UI) */
export function validateKeywordExpr(
exprText: string | undefined | null,
mode: KeywordExprMode = 'and'
): { ok: boolean; error?: string } {
if (exprText == null || !String(exprText).trim()) {
return { ok: true };
}
const text = String(exprText).trim();
if (!hasExprOperators(text)) {
return { ok: true };
}
try {
const tokens = tokenize(text);
if (tokens.length === 0) return { ok: true };
parseTokens(tokens);
return { ok: true };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : '表达式解析失败',
};
}
}
// ---------------------------------------------------------------------------
// 单集只下一次:同集择优
// ---------------------------------------------------------------------------
/** 网页友好打分:内嵌 > 内封,简日双语 > 简中 */
export function scoreTorrentTitle(title: string): number {
let score = 0;
if (/简日双语|简日雙語/.test(title)) score += 5;
else if (/简日内嵌|簡日內嵌/.test(title)) score += 4;
else if (/简中|简体|CHS|GB/i.test(title)) score += 3;
else if (/简日/.test(title)) score += 2;
if (/内嵌|內嵌/.test(title)) score += 4;
else if (/内封|內封/.test(title)) score += 1;
if (/1080/.test(title)) score += 2;
else if (/720/.test(title)) score -= 1;
// MP4 略优于默认(网页更友好)
if (/MP4|mp4/.test(title)) score += 1;
return score;
}
export interface EpisodeCandidate {
episode: number;
title: string;
[key: string]: unknown;
}
/**
* 每个集数只保留打分最高的一条(同分保留先出现的)
*/
export function pickOnePerEpisode<T extends EpisodeCandidate>(items: T[]): T[] {
const best = new Map<number, T>();
for (const item of items) {
const prev = best.get(item.episode);
if (!prev) {
best.set(item.episode, item);
continue;
}
const sNew = scoreTorrentTitle(item.title);
const sOld = scoreTorrentTitle(prev.title);
if (sNew > sOld) {
best.set(item.episode, item);
}
}
return Array.from(best.values()).sort((a, b) => a.episode - b.episode);
}
+222 -45
View File
@@ -2,6 +2,11 @@
import parseTorrentName from 'parse-torrent-name';
import { parseStringPromise } from 'xml2js';
import {
matchesExclude,
matchesFilter,
pickOnePerEpisode,
} from '@/lib/anime-keyword-expr';
import { getConfig, setCachedConfig } from '@/lib/config';
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
import { db, getStorage } from '@/lib/db';
@@ -13,6 +18,16 @@ import {
} from '@/lib/openlist-offline-download';
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
// 兼容外部从本模块引用匹配工具(仅服务端使用本文件;客户端请直接 import anime-keyword-expr
export {
isAnimeCategoryText,
matchesExclude,
matchesFilter,
pickOnePerEpisode,
scoreTorrentTitle,
validateKeywordExpr,
} from '@/lib/anime-keyword-expr';
const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
const pickRssText = (value: any): string => {
@@ -25,10 +40,51 @@ const pickRssText = (value: any): string => {
function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool {
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
? tool as AnimeSubscriptionDownloadTool
? (tool as AnimeSubscriptionDownloadTool)
: 'aria2';
}
/**
* 搜索用集数 token:个位数补零(2 → 02),≥10 原样
*/
export function formatEpisodeSearchToken(episode: number): string {
if (!Number.isFinite(episode) || episode < 0) return '';
const n = Math.floor(episode);
return n < 10 ? String(n).padStart(2, '0') : String(n);
}
/**
* 标题是否明确包含目标集数(避免 1080/720/年份等误命中)
* 认可形态示例:[02]、[2]、第02集、EP02、E02、 - 02 [
*/
export function titleContainsEpisode(title: string, episode: number): boolean {
if (!title || !Number.isFinite(episode) || episode <= 0) return false;
const ep = Math.floor(episode);
const padded = formatEpisodeSearchToken(ep);
const raw = String(ep);
// 先挖掉分辨率/常见非集数数字,降低误判
const cleaned = title
.replace(/(?:^|[^0-9])(?:240|360|480|720|1080|1440|2160|4k|8k)(?:p|P|i|I)?(?![0-9])/g, ' ')
.replace(/(?:19|20)\d{2}/g, ' '); // 年份
const patterns: RegExp[] = [
new RegExp(`\\[0*${ep}\\]`), // [02] [2]
new RegExp(`第0*${ep}[集话話]`),
new RegExp(`(?:^|[^A-Za-z0-9])EP?0*${ep}(?![0-9])`, 'i'), // EP02 E02
new RegExp(`(?:^|[^0-9])0*${ep}(?=\\s*[\\]\\-–—_]|\\s+\\[)`), // 02] / 02 - / 02 [
new RegExp(`[-–—_]\\s*0*${ep}(?![0-9])`), // - 02
new RegExp(`\\s0*${ep}\\s`), // 空格02空格
];
// padded 与 raw 在部分形态下等价(上面已用 0*ep);额外允许字面 [02]
if (padded !== raw) {
patterns.push(new RegExp(`\\[${padded}\\]`));
}
return patterns.some((re) => re.test(cleaned) || re.test(title));
}
/**
* 从标题中提取集数
*/
@@ -36,59 +92,157 @@ export function extractEpisode(title: string): number | null {
const parsed = parseTorrentName(title);
if (parsed.episode) {
return parsed.episode;
const ep = Number(parsed.episode);
// 过滤明显非集数(分辨率等)
if (ep > 0 && ep < 1000 && ![480, 720, 1080, 1440, 2160].includes(ep)) {
if (titleContainsEpisode(title, ep) || ep < 100) {
return ep;
}
}
}
// 备用正则匹配
const patterns = [
/\[(\d+)\]/, // [01]
/第(\d+)[集话]/, // 第01集
/EP?(\d+)/i, // EP01, E01
/\s(\d+)\s/, // 空格01空格
// 备用正则匹配(带集数语义,避免裸数字)
const patterns: Array<[RegExp, number]> = [
[/\[(\d{1,3})\]/, 1], // [01]
[/第(\d{1,3})[集话]/, 1], // 第01集
[/(?:^|[^A-Za-z0-9])EP?(\d{1,3})(?![0-9])/i, 1], // EP01, E01
[/[-–—_]\s*(\d{1,3})(?![0-9])/, 1], // - 01
[/\s(\d{1,3})\s/, 1], // 空格01空格(最后兜底)
];
for (const pattern of patterns) {
for (const [pattern] of patterns) {
const match = title.match(pattern);
if (match) {
return parseInt(match[1], 10);
const ep = parseInt(match[1], 10);
if (
!Number.isFinite(ep) ||
ep <= 0 ||
ep >= 1000 ||
[480, 720, 1080, 1440, 2160].includes(ep)
) {
continue;
}
// 空格数字兜底时必须再过 titleContainsEpisode,降低误伤
if (pattern.source.includes('\\s') && !titleContainsEpisode(title, ep)) {
continue;
}
return ep;
}
}
return null;
}
/**
* 解析逗号分隔关键词(兼容中文逗号)
*/
function parseKeywords(text: string): string[] {
return text
.replace(//g, ',')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
type AcgSearchItem = {
title: string;
link?: string;
guid?: string;
pubDate?: string;
torrentUrl?: string;
description?: string;
episode?: number | null;
};
function filterAndParseEpisodes(
results: AcgSearchItem[],
subscription: AnimeSubscription,
opts?: { onlyEpisode?: number; minEpisodeExclusive?: number }
): AcgSearchItem[] {
const only = opts?.onlyEpisode;
const minExclusive = opts?.minEpisodeExclusive ?? -Infinity;
return results
.filter((item) => matchesFilter(item.title, subscription.filterText))
.filter((item) => !matchesExclude(item.title, subscription.excludeText))
.map((item) => {
const episode = extractEpisode(item.title);
return { ...item, episode };
})
.filter((item) => {
if (!item.episode) return false;
if (only != null) {
return (
item.episode === only && titleContainsEpisode(item.title, only)
);
}
return item.episode > minExclusive;
})
.sort((a, b) => (a.episode || 0) - (b.episode || 0));
}
/**
* 检查标题是否匹配过滤条件(包含关键词,AND:必须全部命中)
* 缺集补搜:在 (lastEpisode, maxFound] 内对未命中集按「番名 + 补零集数」再搜
*/
export function matchesFilter(title: string, filterText: string): boolean {
if (!filterText) return true;
async function refillMissingEpisodeResults(
subscription: AnimeSubscription,
existing: AcgSearchItem[]
): Promise<AcgSearchItem[]> {
const last = subscription.lastEpisode || 0;
const foundEps = new Set(
existing
.map((i) => i.episode)
.filter((ep): ep is number => typeof ep === 'number' && ep > last)
);
if (foundEps.size === 0) return existing;
// 支持多个关键词,用逗号分隔,必须全部匹配
const keywords = parseKeywords(filterText);
const maxFound = Math.max(...Array.from(foundEps));
const missing: number[] = [];
for (let ep = last + 1; ep <= maxFound; ep += 1) {
if (!foundEps.has(ep)) missing.push(ep);
}
if (missing.length === 0) return existing;
return keywords.every((keyword) => title.includes(keyword));
}
// 单次检查最多补搜 24 集,避免源站压力过大
const toSearch = missing.slice(0, 24);
console.log(
`[AnimeSubscription] ${subscription.title}: 缺集重新检索 ${toSearch.join(
','
)}(上限内;总缺 ${missing.length}`
);
/**
* 检查标题是否命中排除关键词(OR:任一命中即排除)
*/
export function matchesExclude(title: string, excludeText?: string): boolean {
if (!excludeText) return false;
const merged = [...existing];
const haveEp = new Set(foundEps);
const keywords = parseKeywords(excludeText);
for (const ep of toSearch) {
const token = formatEpisodeSearchToken(ep);
const keyword = `${subscription.title} ${token}`.trim();
try {
const results = await searchACG(keyword, subscription.source);
const matched = filterAndParseEpisodes(results, subscription, {
onlyEpisode: ep,
});
if (matched.length === 0) {
console.log(
`[AnimeSubscription] ${subscription.title}: 补搜「${keyword}」未命中第${ep}`
);
continue;
}
for (const item of matched) {
if (item.episode && !haveEp.has(item.episode)) {
// 同集先都放进池子,后续 onePerEpisode 再择优
}
merged.push(item);
}
haveEp.add(ep);
console.log(
`[AnimeSubscription] ${subscription.title}: 补搜第${ep}集命中 ${matched.length}`
);
} catch (err) {
console.error(
`[AnimeSubscription] ${subscription.title}: 补搜第${ep}集失败`,
err
);
}
}
return keywords.some((keyword) => title.includes(keyword));
return merged
.filter(
(item) =>
item.episode &&
item.episode > last &&
titleContainsEpisode(item.title, item.episode)
)
.sort((a, b) => (a.episode || 0) - (b.episode || 0));
}
/**
@@ -340,20 +494,43 @@ export async function checkSubscription(subscription: AnimeSubscription) {
// 1. 搜索资源
const results = await searchACG(subscription.title, subscription.source);
// 2. 过滤并解析集数(包含关键词 AND,排除关键词 OR
const newEpisodes = results
.filter((item: any) => matchesFilter(item.title, subscription.filterText))
.filter((item: any) => !matchesExclude(item.title, subscription.excludeText))
.map((item: any) => ({
episode: extractEpisode(item.title),
...item,
}))
.filter((item: any) => item.episode && item.episode > subscription.lastEpisode)
.sort((a: any, b: any) => a.episode! - b.episode!);
// 2. 过滤并解析集数(关键词支持 & | ();旧逗号兼容
let newEpisodes = filterAndParseEpisodes(results, subscription, {
minEpisodeExclusive: subscription.lastEpisode,
});
// 2a. 缺集重新检索(可选):首搜跳集时按「番名 + 补零集数」补搜中间集
if (subscription.refillMissingEpisodes) {
newEpisodes = await refillMissingEpisodeResults(subscription, newEpisodes);
}
// 2b. 单集只下载一次(每条订阅可选,默认关)
if (subscription.onePerEpisode) {
const before = newEpisodes.length;
newEpisodes = pickOnePerEpisode(
newEpisodes.filter(
(item): item is AcgSearchItem & { episode: number; title: string } =>
typeof item.episode === 'number' && !!item.title
)
);
if (before > newEpisodes.length) {
console.log(
`[AnimeSubscription] ${subscription.title}: 单集只下一次,${before}${newEpisodes.length}`
);
for (const item of newEpisodes) {
console.log(
`[AnimeSubscription] ${subscription.title}: 第${item.episode}集选用「${item.title}`
);
}
}
}
// 3. 下载新集数
const downloaded = [];
const downloaded: number[] = [];
for (const item of newEpisodes) {
if (typeof item.episode !== 'number' || !item.torrentUrl) {
continue;
}
try {
const downloadPath = joinOpenListPath(
getOfflineDownloadBasePath(config),
@@ -362,7 +539,7 @@ export async function checkSubscription(subscription: AnimeSubscription) {
await addOfflineDownload(item.torrentUrl, downloadPath);
// 成功后更新 lastEpisode
subscription.lastEpisode = item.episode!;
subscription.lastEpisode = item.episode;
downloaded.push(item.episode);
console.log(
+7 -4
View File
@@ -116,9 +116,9 @@ export class D1Storage implements IStorage {
INSERT INTO play_records (
username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time,
save_time, search_title, new_episodes
save_time, search_title, new_episodes, is_anime
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
title = excluded.title,
source_name = excluded.source_name,
@@ -130,7 +130,8 @@ export class D1Storage implements IStorage {
total_time = excluded.total_time,
save_time = excluded.save_time,
search_title = excluded.search_title,
new_episodes = excluded.new_episodes
new_episodes = excluded.new_episodes,
is_anime = excluded.is_anime
`
)
.bind(
@@ -146,7 +147,8 @@ export class D1Storage implements IStorage {
record.total_time,
record.save_time,
record.search_title || '',
record.new_episodes || null
record.new_episodes || null,
record.is_anime ? 1 : 0
)
.run();
} catch (err) {
@@ -1239,6 +1241,7 @@ export class D1Storage implements IStorage {
save_time: row.save_time,
search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined,
is_anime: row.is_anime === 1 || row.is_anime === true,
};
}
+2
View File
@@ -44,6 +44,8 @@ export interface PlayRecord {
search_title?: string; // 搜索时使用的标题
origin?: 'vod' | 'live'; // 来源类型
new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
/** 是否动漫(写入时根据 CMS type_name/class 判断) */
is_anime?: boolean;
}
// ---- 收藏类型 ----
+7 -4
View File
@@ -109,9 +109,9 @@ export class PostgresStorage implements IStorage {
INSERT INTO play_records (
username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time,
save_time, search_title, new_episodes
save_time, search_title, new_episodes, is_anime
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (username, key) DO UPDATE SET
title = EXCLUDED.title,
source_name = EXCLUDED.source_name,
@@ -123,7 +123,8 @@ export class PostgresStorage implements IStorage {
total_time = EXCLUDED.total_time,
save_time = EXCLUDED.save_time,
search_title = EXCLUDED.search_title,
new_episodes = EXCLUDED.new_episodes
new_episodes = EXCLUDED.new_episodes,
is_anime = EXCLUDED.is_anime
`
)
.bind(
@@ -139,7 +140,8 @@ export class PostgresStorage implements IStorage {
record.total_time,
record.save_time,
record.search_title || '',
record.new_episodes || null
record.new_episodes || null,
record.is_anime ? 1 : 0
)
.run();
} catch (err) {
@@ -390,6 +392,7 @@ export class PostgresStorage implements IStorage {
save_time: row.save_time,
search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined,
is_anime: row.is_anime === 1 || row.is_anime === true,
};
}
+3
View File
@@ -15,6 +15,9 @@ export interface PlayRecord {
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
origin?: 'vod' | 'live';
/** 是否动漫(写入时根据 CMS type_name/class 判断) */
is_anime?: boolean;
}
// 收藏数据结构