修复合并源时同一个源不能参与不同组合并的问题 (#230)
* merge-util.js:巩固过滤集相关函数,修复同一个源不能参与多组合并配置 * bahamut.js:将tmdb智能标题前的标题传给合并工具 解决Fate/strange Fake智能替换成中文别名后无法关联的问题 * bilibili.js:将接口中的org_title条目原名也传给合并工具对比 解决 命运-奇异赝品 黎明低语 无法与英文别名关联 * bahamut.js:修复时间轴取整导致的去重偏差 * merge-util.js:修复执行顺序bug * merge-util.js:修复合集复用副源bug,调整结果排序为用户配置优先 * common-util.js:清理标题空格的同时清理修饰性符号 修复【我推的孩子】括号带与不带时被过滤掉了 * bahamut.js:去除多余空格
This commit is contained in:
1 parent
c5b7221997
commit
2dac103629
4 files changed
+141
-77
No files matched your search
@@ -281,6 +281,11 @@ export default class BahamutSource extends BaseSource {
|
||||
return bahamutTitleMatches(itemTitle, queryTitle, usedSearchTitle);
|
||||
});
|
||||
|
||||
// 记录替换前的原始标题,作为别名传递给合并工具进行比对
|
||||
filtered.forEach(item => {
|
||||
item._originalTitleAlias = item.title ? simplized(item.title) : "";
|
||||
});
|
||||
|
||||
// 应用tmdb智能标题替换
|
||||
const cnAlias = filtered.length > 0 ? filtered[0]._tmdbCnAlias : null;
|
||||
smartTitleReplace(filtered, cnAlias);
|
||||
@@ -317,6 +322,12 @@ export default class BahamutSource extends BaseSource {
|
||||
// 优先使用tmdb智能标题替换的标题,否则简转繁处理原标题
|
||||
const displayTitle = anime._displayTitle || simplized(anime.title);
|
||||
|
||||
// 提取原始标题作为别名
|
||||
const aliases = [];
|
||||
if (anime._originalTitleAlias && anime._originalTitleAlias !== displayTitle) {
|
||||
aliases.push(anime._originalTitleAlias);
|
||||
}
|
||||
|
||||
// 解析剧集类型
|
||||
let itemType = "动漫"; // 默认类型
|
||||
// 从 epData 中获取完整标题 (优先使用 anime.title)
|
||||
@@ -332,6 +343,7 @@ export default class BahamutSource extends BaseSource {
|
||||
animeId: anime.video_sn,
|
||||
bangumiId: String(anime.video_sn),
|
||||
animeTitle: `${displayTitle}(${(anime.info.match(/(\d{4})/) || [null])[0]})【${itemType}】from bahamut`,
|
||||
aliases: aliases,
|
||||
type: "动漫",
|
||||
typeDescription: "动漫",
|
||||
imageUrl: anime.cover,
|
||||
@@ -412,10 +424,10 @@ export default class BahamutSource extends BaseSource {
|
||||
const positionToMode = { 0: 1, 1: 5, 2: 4 };
|
||||
return comments.map(c => ({
|
||||
cid: Number(c.sn),
|
||||
p: `${Math.round(c.time / 10).toFixed(2)},${positionToMode[c.position] || c.tp},${parseInt(c.color.slice(1), 16)},[bahamut]`,
|
||||
p: `${(c.time / 10).toFixed(2)},${positionToMode[c.position] || c.tp},${parseInt(c.color.slice(1), 16)},[bahamut]`,
|
||||
// 根据 globals.danmuSimplifiedTraditional 控制是否繁转简
|
||||
m: globals.danmuSimplifiedTraditional === 'simplified' ? simplized(c.text) : c.text,
|
||||
t: Math.round(c.time / 10)
|
||||
t: c.time / 10
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -434,13 +434,24 @@ export default class BilibiliSource extends BaseSource {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 提取并备份原始标题与接口提取的 org_title 作为别名
|
||||
sourceAnimes.forEach(anime => {
|
||||
anime.aliases = anime.aliases || [];
|
||||
if (anime.title && !anime.aliases.includes(anime.title)) {
|
||||
anime.aliases.push(anime.title);
|
||||
}
|
||||
if (anime.org_title && !anime.aliases.includes(anime.org_title)) {
|
||||
anime.aliases.push(anime.org_title);
|
||||
}
|
||||
});
|
||||
|
||||
// 应用tmdb智能标题替换
|
||||
const cnAlias = sourceAnimes.length > 0 ? sourceAnimes[0]._tmdbCnAlias : null;
|
||||
smartTitleReplace(sourceAnimes, cnAlias);
|
||||
|
||||
const processPromises = sourceAnimes
|
||||
// 港澳台资源不做严格标题匹配,因为可能搜到的是日语/繁体标题
|
||||
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)||(anime.org_title && titleMatches(anime.org_title, queryTitle)))
|
||||
// 港澳台资源不做严格标题匹配,其他资源根据当前标题或别名池(已包含原标题和 org_title)验证查询匹配度
|
||||
.filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle) || (anime.aliases && anime.aliases.some(alias => titleMatches(alias, queryTitle))))
|
||||
.map(async (anime) => {
|
||||
try {
|
||||
let links = [];
|
||||
@@ -523,6 +534,7 @@ export default class BilibiliSource extends BaseSource {
|
||||
rating: 0,
|
||||
isFavorited: true,
|
||||
source: "bilibili",
|
||||
aliases: anime.aliases
|
||||
};
|
||||
|
||||
tmpAnimes.push(transformedAnime);
|
||||
@@ -893,13 +905,14 @@ export default class BilibiliSource extends BaseSource {
|
||||
const data = await this._fetchAppSearchWithStream(url, { "User-Agent": "Mozilla/5.0 Android", "X-From-Biliroaming": "1.0.0" }, label, signal);
|
||||
|
||||
if (data && data.code === 0) {
|
||||
// 兼容 items (影视/综艺) 和 result (番剧) 两种字段结构
|
||||
// 兼容 items (影视/综艺) 和 result (番剧) 两种字段结构,提取返回的 org_title 字段
|
||||
return (data.data?.items || data.data?.result || data.data || [])
|
||||
.filter(i => i.goto !== 'recommend_tips' && i.area !== '漫游' && i.badge !== '公告')
|
||||
.map(i => ({
|
||||
provider: "bilibili",
|
||||
mediaId: i.season_id ? `ss${i.season_id}` : (i.uri.match(/season\/(\d+)/)?.[1] ? `ss${i.uri.match(/season\/(\d+)/)[1]}` : ""),
|
||||
title: (i.title||"").replace(/<[^>]+>/g,'').trim(),
|
||||
org_title: (i.org_title||"").replace(/<[^>]+>/g,'').trim(),
|
||||
type: this._extractMediaType(i.season_type_name),
|
||||
year: i.ptime ? new Date(i.ptime*1000).getFullYear() : null,
|
||||
imageUrl: i.cover||i.pic||"",
|
||||
@@ -938,8 +951,10 @@ export default class BilibiliSource extends BaseSource {
|
||||
return [];
|
||||
}
|
||||
if(data.data?.result) {
|
||||
// 在 Web Fallback 提取并清洗 org_title 字段
|
||||
return data.data.result.filter(i => i.url?.includes("bilibili.com") && (!i.areas?.includes("漫游"))).map(i => ({
|
||||
provider: "bilibili", mediaId: i.season_id?`ss${i.season_id}`:"", title: (i.title||"").replace(/<[^>]+>/g,'').trim(),
|
||||
org_title: (i.org_title || "").replace(/<[^>]+>/g,'').replace(/&[^;]+;/g, match => { const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" }; return entities[match] || match; }).trim(),
|
||||
type: this._extractMediaType(i.season_type_name), year: i.pubtime?new Date(i.pubtime*1000).getFullYear():null, imageUrl: i.cover||null,
|
||||
episodeCount: i.ep_size||0, _eps: i.eps, isOversea: true
|
||||
})).filter(i => i.mediaId);
|
||||
|
||||
@@ -192,14 +192,14 @@ export function createDynamicPlatformOrder(preferredPlatform) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化标题中的空格(移除所有空格以便进行空格无关的匹配)
|
||||
* 规范化标题(移除空格并清理修饰性符号)
|
||||
* @param {string} str - 输入字符串
|
||||
* @returns {string} 规范化后的字符串(移除所有空格)
|
||||
* @returns {string} 规范化后的字符串
|
||||
*/
|
||||
export function normalizeSpaces(str) {
|
||||
if (!str) return '';
|
||||
// 移除所有空格(包括多个连续空格、制表符等)
|
||||
return String(str).trim().replace(/\s+/g, '');
|
||||
// 移除所有空格与修饰性符号(包括多个连续空格、制表符等)
|
||||
return String(str).trim().replace(/[\s【】\[\]《》<>「」!?!?.,,。~~]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,7 +265,7 @@ export function titleMatches(title, query) {
|
||||
const querySeason = getExplicitSeasonNumber(query);
|
||||
if (querySeason !== null) {
|
||||
const titleSeason = getExplicitSeasonNumber(title);
|
||||
|
||||
|
||||
if (querySeason > 1) {
|
||||
// 搜索指定续作(>1)时,标题必须明确包含该季度标识
|
||||
if ((titleSeason || 1) !== querySeason) return false;
|
||||
@@ -280,7 +280,7 @@ export function titleMatches(title, query) {
|
||||
const qSet = new Set(q);
|
||||
const tSet = new Set(t);
|
||||
const matchCount = [...qSet].reduce((acc, char) => acc + (tSet.has(char) ? 1 : 0), 0);
|
||||
|
||||
|
||||
return (matchCount / qSet.size) > 0.8;
|
||||
}
|
||||
|
||||
|
||||
+103
-66
@@ -205,14 +205,14 @@ const SUFFIX_SPECIFIC_MAP = [
|
||||
];
|
||||
|
||||
const SEASON_PATTERNS = [
|
||||
{ regex: /(?:第)?(\d+)(?:季|期|部(?!分))/, prefix: 'S' },
|
||||
{ regex: /season\s*(\d+)/, prefix: 'S' },
|
||||
{ regex: /s(\d+)/, prefix: 'S' },
|
||||
{ regex: /part\s*(\d+)/, prefix: 'P' },
|
||||
{ regex: /(ova|oad)/, val: 'OVA' },
|
||||
{ regex: /(剧场版|the\s*movie|theatrical|movie|film|电影)/, val: 'MOVIE' },
|
||||
{ regex: /(?:第)?(\d+)(?:季|期|部(?!分))/, prefix: 'S' },
|
||||
{ regex: /\bseason\s*(\d+)/i, prefix: 'S' },
|
||||
{ regex: /\bs\s*(\d+)\b/i, prefix: 'S' },
|
||||
{ regex: /\bpart\s*(\d+)/i, prefix: 'P' },
|
||||
{ regex: /\b(ova|oad)\d*\b/i, val: 'OVA' },
|
||||
{ regex: /(剧场版|the\s*movie|theatrical|movie|film|电影)/i, val: 'MOVIE' },
|
||||
{ regex: /(续篇|续集)/, val: 'SEQUEL' },
|
||||
{ regex: /sp/, val: 'SP' },
|
||||
{ regex: /\b(sp|special)\d*\b/i, val: 'SP' },
|
||||
{ regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true }
|
||||
];
|
||||
|
||||
@@ -560,8 +560,8 @@ function extractSeasonMarkers(title, typeDesc = '') {
|
||||
}
|
||||
if (RegexStore.Season.SUFFIX_SEQUEL.test(t) || type.includes('续篇')) markers.add('SEQUEL');
|
||||
if (type.includes('剧场版') || type.includes('movie') || type.includes('film') || type.includes('电影')) markers.add('MOVIE');
|
||||
if (type.includes('ova') || type.includes('oad')) markers.add('OVA');
|
||||
if (type.includes('sp') || type.includes('special')) markers.add('SP');
|
||||
if (/\b(ova|oad)\b/i.test(type)) markers.add('OVA');
|
||||
if (/\b(sp|special)\b/i.test(type)) markers.add('SP');
|
||||
const cnNums = {'一':1, '二':2, '三':3, '四':4, '五':5, 'final': 99};
|
||||
for (const [cn, num] of Object.entries(cnNums)) {
|
||||
if (t.includes(`第${cn}季`)) markers.add(`S${num}`);
|
||||
@@ -1133,19 +1133,43 @@ function extractEpisodeInfo(title, sourceName = '') {
|
||||
|
||||
/**
|
||||
* 过滤无效剧集 (基于标题正则)
|
||||
* @param {Array} links
|
||||
* @param {RegExp} filterRegex
|
||||
* @returns {Array}
|
||||
* 特定高置信度源跳过正则过滤,防止常规集数因命中全局正则而丢失
|
||||
* @param {Array} links 集数对象列表
|
||||
* @param {RegExp} filterRegex 过滤正则
|
||||
* @param {string} [sourceName=''] 来源平台名称
|
||||
* @returns {Array} 携带原始索引的过滤后列表
|
||||
*/
|
||||
function filterEpisodes(links, filterRegex) {
|
||||
function filterEpisodes(links, filterRegex, sourceName = '') {
|
||||
if (!links) return [];
|
||||
if (!filterRegex) return links.map((link, index) => ({ link, originalIndex: index }));
|
||||
return links
|
||||
.map((link, index) => ({ link, originalIndex: index }))
|
||||
.filter(item => {
|
||||
const title = item.link.title || item.link.name || "";
|
||||
return !filterRegex.test(title);
|
||||
});
|
||||
|
||||
// 特定源白名单:这些源的集标题通常比较规范,免除正则过滤拦截
|
||||
const skipFilterSources = ['animeko', 'bilibili', 'bilibili1', 'bahamut', 'dandan'];
|
||||
const shouldSkipFilter = skipFilterSources.includes(sourceName);
|
||||
|
||||
if (!filterRegex || shouldSkipFilter) {
|
||||
return links.map((link, index) => ({ link, originalIndex: index }));
|
||||
}
|
||||
|
||||
const validLinks = [];
|
||||
const droppedTitles = [];
|
||||
|
||||
// 遍历并拦截命中规则的条目
|
||||
links.forEach((link, index) => {
|
||||
const title = link.title || link.name || "";
|
||||
if (filterRegex.test(title)) {
|
||||
droppedTitles.push(title);
|
||||
} else {
|
||||
validLinks.push({ link, originalIndex: index });
|
||||
}
|
||||
});
|
||||
|
||||
// 集中输出被过滤的条目日志
|
||||
if (droppedTitles.length > 0) {
|
||||
const sourcePrefix = sourceName ? `[${sourceName}] ` : '';
|
||||
log("info", `[Merge-Check] ${sourcePrefix}命中EPISODE_TITLE_FILTER过滤,已前置剔除 ${droppedTitles.length} 集: ${droppedTitles.join(', ')}`);
|
||||
}
|
||||
|
||||
return validLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1533,7 +1557,7 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
|
||||
|
||||
const seasonNum = getSeasonNumber(realAnime.animeTitle, realAnime.typeDescription);
|
||||
if (seasonNum !== null && realAnime.links) {
|
||||
const validLinks = filterEpisodes(realAnime.links, epFilter).filter(item => {
|
||||
const validLinks = filterEpisodes(realAnime.links, epFilter, realAnime.source).filter(item => {
|
||||
const title = item.link.title || item.link.name || "";
|
||||
const cleanT = cleanText(title);
|
||||
const rawTemp = cleanT.replace(RegexStore.Clean.SOURCE_TAG, '').replace(RegexStore.Clean.FROM_SUFFIX, '').trim();
|
||||
@@ -1641,8 +1665,9 @@ async function processMergeTask(params) {
|
||||
limitSecondaryLang, collectionAnimeIds, allowReuseIds, collectionProgress
|
||||
} = params;
|
||||
|
||||
if (collectionAnimeIds.has(pAnime.animeId) && globalConsumedIds.has(pAnime.animeId)) {
|
||||
log("info", `${logPrefix} 跳过: [${currentPrimarySource}] 是合集且已作为副源参与过合并。`);
|
||||
// 在一组中是合集且已作为副源参与过合并就跳过,另一组相互独立互不干扰。
|
||||
if (collectionAnimeIds.has(pAnime.animeId) && groupConsumedIds.has(pAnime.animeId)) {
|
||||
log("info", `${logPrefix} 跳过: [${currentPrimarySource}] 是合集且已作为组内副源参与过合并。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1683,6 +1708,10 @@ async function processMergeTask(params) {
|
||||
for (const secSource of availableSecondaries) {
|
||||
let secondaryItems = curAnimes.filter(a => {
|
||||
if (a.source !== secSource) return false;
|
||||
|
||||
// 合集主源特权:当主源为合集时,允许无视当前组的消耗状态,复用已被消耗的副源以拼凑完整季度
|
||||
if (isPrimaryCollection) return true;
|
||||
|
||||
const isConsumed = groupConsumedIds.has(a.animeId);
|
||||
const isAllowedReuse = allowReuseIds && allowReuseIds.has(a.animeId);
|
||||
if (isConsumed && !isAllowedReuse) return false;
|
||||
@@ -1704,32 +1733,35 @@ async function processMergeTask(params) {
|
||||
const matches = findSecondaryMatches(pAnime, secondaryItems, collectionAnimeIds);
|
||||
|
||||
for (const match of matches) {
|
||||
const isReuse = allowReuseIds && allowReuseIds.has(match.animeId);
|
||||
if (!isReuse && groupConsumedIds.has(match.animeId)) continue;
|
||||
// 合集主源特权:如果是合集主源,直接跳过此处的二次消耗校验
|
||||
if (!isPrimaryCollection) {
|
||||
const isReuse = allowReuseIds && allowReuseIds.has(match.animeId);
|
||||
if (!isReuse && groupConsumedIds.has(match.animeId)) continue;
|
||||
}
|
||||
|
||||
const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
|
||||
if (!cachedMatch?.links) continue;
|
||||
const globalCachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
|
||||
if (!globalCachedMatch?.links) continue;
|
||||
const derivedMatch = fastCloneAnime(globalCachedMatch);
|
||||
|
||||
const mappingEntries = [], matchedPIndices = new Set(), pendingMutations = [], orphanedEpisodes = [];
|
||||
const logTitleB = cachedMatch.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, '');
|
||||
const logTitleB = derivedMatch.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, '');
|
||||
|
||||
const decimalsP = getDecimalEpisodes(derivedAnime.links, currentPrimarySource);
|
||||
const decimalsS = getDecimalEpisodes(cachedMatch.links, secSource);
|
||||
const decimalsS = getDecimalEpisodes(derivedMatch.links, secSource);
|
||||
const toSinkS = new Set([...decimalsS].filter(x => !decimalsP.has(x)));
|
||||
const toSinkP = new Set([...decimalsP].filter(x => !decimalsS.has(x)));
|
||||
|
||||
if (toSinkP.size > 0) sinkDecimalEpisodes(derivedAnime.links, toSinkP, currentPrimarySource, `主源:${currentPrimarySource}`);
|
||||
|
||||
let currentSecondaryLinks = cachedMatch.links;
|
||||
if (toSinkS.size > 0) {
|
||||
currentSecondaryLinks = [...cachedMatch.links];
|
||||
sinkDecimalEpisodes(currentSecondaryLinks, toSinkS, secSource, `副源:${secSource}`);
|
||||
sinkDecimalEpisodes(derivedMatch.links, toSinkS, secSource, `副源:${secSource}`);
|
||||
}
|
||||
let currentSecondaryLinks = derivedMatch.links;
|
||||
|
||||
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
|
||||
const filteredMLinksWithIndex = filterEpisodes(currentSecondaryLinks, epFilter);
|
||||
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter, currentPrimarySource);
|
||||
const filteredMLinksWithIndex = filterEpisodes(currentSecondaryLinks, epFilter, secSource);
|
||||
|
||||
const seriesLangB = getLanguageType(cachedMatch.animeTitle);
|
||||
const seriesLangB = getLanguageType(derivedMatch.animeTitle);
|
||||
let activePLinks = filteredPLinksWithIndex, activeMLinks = filteredMLinksWithIndex;
|
||||
let sliceStartP = 0, sliceStartS = 0;
|
||||
const isSecondaryCollection = collectionAnimeIds.has(match.animeId);
|
||||
@@ -1790,7 +1822,7 @@ async function processMergeTask(params) {
|
||||
};
|
||||
|
||||
if (isPrimaryCollection && !isSecondaryCollection) {
|
||||
const secSeason = getSeasonNumber(cachedMatch.animeTitle, cachedMatch.typeDescription);
|
||||
const secSeason = getSeasonNumber(derivedMatch.animeTitle, derivedMatch.typeDescription);
|
||||
const res = performSlicing(true, filteredPLinksWithIndex, secSeason);
|
||||
activePLinks = res.slicedList; sliceStartP = res.sliceStart;
|
||||
} else if (!isPrimaryCollection && isSecondaryCollection) {
|
||||
@@ -1799,7 +1831,7 @@ async function processMergeTask(params) {
|
||||
activeMLinks = res.slicedList; sliceStartS = res.sliceStart;
|
||||
}
|
||||
|
||||
const bestOffsetLocal = findBestAlignmentOffset(activePLinks, activeMLinks, seriesLangA, seriesLangB, currentPrimarySource, secSource, pAnime.animeTitle, cachedMatch.animeTitle);
|
||||
const bestOffsetLocal = findBestAlignmentOffset(activePLinks, activeMLinks, seriesLangA, seriesLangB, currentPrimarySource, secSource, pAnime.animeTitle, derivedMatch.animeTitle);
|
||||
const offset = bestOffsetLocal + sliceStartP - sliceStartS;
|
||||
if (offset !== 0) log("info", `${logPrefix} 集数自动对齐 (${secSource}): Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`);
|
||||
|
||||
@@ -1807,7 +1839,7 @@ async function processMergeTask(params) {
|
||||
derivedAnime.bangumiId = String(derivedAnime.animeId);
|
||||
|
||||
let mergedCount = 0;
|
||||
const redundantS = identifyRedundantTitle(cachedMatch.links, cachedMatch.animeTitle, secSource);
|
||||
const redundantS = identifyRedundantTitle(derivedMatch.links, derivedMatch.animeTitle, secSource);
|
||||
|
||||
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
|
||||
const pIndex = k + offset;
|
||||
@@ -1818,11 +1850,6 @@ async function processMergeTask(params) {
|
||||
const orphanItem = { link: sourceLink, originalIndex: sourceLinkItem.originalIndex, relativeIndex: pIndex, info: null };
|
||||
const cleanTitleS = getTempTitle(sourceLink.title, redundantS);
|
||||
orphanItem.info = extractEpisodeInfo(cleanTitleS, secSource);
|
||||
|
||||
if (epFilter && epFilter.test(sTitleShort)) {
|
||||
mappingEntries.push({ idx: pIndex, text: ` [略过] ${sTitleShort} (命中PV/预告过滤器)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pIndex >= 0 && pIndex < filteredPLinksWithIndex.length) {
|
||||
const originalPIndex = filteredPLinksWithIndex[pIndex].originalIndex;
|
||||
@@ -1938,9 +1965,6 @@ async function processMergeTask(params) {
|
||||
derivedAnime.links = [...normals, ...sinkers];
|
||||
log("info", `${logPrefix} [排序优化] 立即执行番外沉底: 移动了 ${sinkers.length} 个番外集到末尾`);
|
||||
}
|
||||
if (!collectionAnimeIds.has(match.animeId)) groupConsumedIds.add(match.animeId);
|
||||
else log("info", `${logPrefix} 合集保留: [${secSource}] ${logTitleB} 是合集,保留以供同组复用。`);
|
||||
globalConsumedIds.add(match.animeId);
|
||||
hasMergedAny = true;
|
||||
actualMergedSources.push(secSource);
|
||||
contentSignatureParts.push(match.animeId);
|
||||
@@ -1953,14 +1977,25 @@ async function processMergeTask(params) {
|
||||
const signature = contentSignatureParts.join('|');
|
||||
if (generatedSignatures.has(signature)) {
|
||||
log("info", `${logPrefix} 检测到重复的合并结果 (Signature: ${signature}),已自动隐去冗余条目。`);
|
||||
return derivedAnime;
|
||||
return null;
|
||||
}
|
||||
generatedSignatures.add(signature);
|
||||
|
||||
for (let i = 1; i < contentSignatureParts.length; i++) {
|
||||
const secId = contentSignatureParts[i];
|
||||
if (!collectionAnimeIds.has(secId)) {
|
||||
groupConsumedIds.add(secId);
|
||||
} else {
|
||||
log("info", `${logPrefix} 合集保留: ID [${secId}] 是合集,保留以供同组复用。`);
|
||||
}
|
||||
globalConsumedIds.add(secId);
|
||||
}
|
||||
|
||||
const joinedSources = actualMergedSources.join(DISPLAY_CONNECTOR);
|
||||
derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${currentPrimarySource}`, `from ${currentPrimarySource}${DISPLAY_CONNECTOR}${joinedSources}`);
|
||||
derivedAnime.source = currentPrimarySource;
|
||||
return derivedAnime;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2176,14 +2211,20 @@ export async function applyMergeLogic(curAnimes) {
|
||||
const groupFingerprint = fullPriorityList.join('&');
|
||||
const groupConsumedIds = new Set();
|
||||
|
||||
// 通用排序函数:媒体类型 (TV>Movie) > 季度编号 ASC (S1->S2) > 源优先级 ASC
|
||||
// 通用排序函数:源优先级 ASC > 媒体类型 (TV>Movie) > 季度编号 ASC (S1->S2)
|
||||
const sortCandidates = (list, phaseName) => {
|
||||
if (!list || list.length < 2) return list;
|
||||
|
||||
log("info", `[Merge-Check] [Sort] ${phaseName} 排序前首个元素: ${list[0].animeTitle}`);
|
||||
|
||||
list.sort((a, b) => {
|
||||
// 优先级 1: 媒体类型 (确保 TV 季度先于 电影/OVA/SP 处理)
|
||||
// 优先级 1: 源优先级 ASC (依据配置文件定义的源顺序,主源总是先于副源执行)
|
||||
// 加上 ?? 99 防止某些未在 map 中的源报错
|
||||
const pA = sourcePriorityMap.get(a.source) ?? 99;
|
||||
const pB = sourcePriorityMap.get(b.source) ?? 99;
|
||||
if (pA !== pB) return pA - pB;
|
||||
|
||||
// 优先级 2: 媒体类型 (确保同源内 TV 季度先于 电影/OVA/SP 处理)
|
||||
// 1 = High Priority (TV/Seasonal), 2 = Low Priority (Movie/Non-seasonal)
|
||||
const getMediaTypePriority = (anime) => {
|
||||
const markers = extractSeasonMarkers(anime.animeTitle, anime.typeDescription);
|
||||
@@ -2201,22 +2242,19 @@ export async function applyMergeLogic(curAnimes) {
|
||||
const typeB = getMediaTypePriority(b);
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
|
||||
// 优先级 2: 季度编号 ASC (确保合集进度记录按顺序产生,S1 先于 S2)
|
||||
// 优先级 3: 季度编号 ASC (确保同源、同类型内,按 S1, S2, S3 顺序执行)
|
||||
const sA = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
|
||||
const sB = getSeasonNumber(b.animeTitle, b.typeDescription) || 1;
|
||||
if (sA !== sB) return sA - sB;
|
||||
|
||||
// 优先级 3: 源优先级 ASC (依据配置文件定义的源顺序)
|
||||
const pA = sourcePriorityMap.get(a.source);
|
||||
const pB = sourcePriorityMap.get(b.source);
|
||||
return pA - pB;
|
||||
return sA - sB;
|
||||
});
|
||||
|
||||
// 优化了调试日志:加入优先级展示,例如 [P0] [S2] [dandan] ...
|
||||
const debugOrder = list.map(a => {
|
||||
const sNum = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
|
||||
// 调试辅助:如果是电影类型,标记为 Movie 以便区分
|
||||
const typeLabel = (extractSeasonMarkers(a.animeTitle, a.typeDescription).has('MOVIE') || getStrictMediaType(a.animeTitle, a.typeDescription) === 'MOVIE') ? 'Movie' : `S${sNum}`;
|
||||
return `[${typeLabel}] [${a.source}] ${a.animeTitle}`;
|
||||
const pLevel = sourcePriorityMap.get(a.source) ?? '?';
|
||||
|
||||
return `[P${pLevel}] [${typeLabel}] [${a.source}] ${a.animeTitle}`;
|
||||
});
|
||||
log("info", `[Merge-Check] [Sort] ${phaseName} 执行顺序:\n ${debugOrder.join('\n ')}`);
|
||||
return list;
|
||||
@@ -2227,14 +2265,14 @@ export async function applyMergeLogic(curAnimes) {
|
||||
// 注意:此处策略稍微放宽,允许列表中所有 CN 源作为发起方尝试匹配,只要它们还没被消费
|
||||
const cnCandidates = [];
|
||||
fullPriorityList.forEach(source => {
|
||||
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
|
||||
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
|
||||
items.forEach(item => cnCandidates.push(item));
|
||||
});
|
||||
|
||||
// 检查副源池中是否有 CN 资源,如果没有则跳过 Phase 1
|
||||
let hasCnInSecondaries = false;
|
||||
for (const secSrc of fullPriorityList) {
|
||||
if (curAnimes.some(a => a.source === secSrc && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN')) {
|
||||
if (curAnimes.some(a => a.source === secSrc && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN')) {
|
||||
hasCnInSecondaries = true;
|
||||
break;
|
||||
}
|
||||
@@ -2246,7 +2284,6 @@ export async function applyMergeLogic(curAnimes) {
|
||||
|
||||
for (const pAnime of cnCandidates) {
|
||||
if (groupConsumedIds.has(pAnime.animeId)) continue;
|
||||
if (globalConsumedIds.has(pAnime.animeId)) continue;
|
||||
|
||||
const currentPriorityIdx = sourcePriorityMap.get(pAnime.source);
|
||||
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
|
||||
@@ -2272,7 +2309,7 @@ export async function applyMergeLogic(curAnimes) {
|
||||
// 从第二个源开始扫描,因为主源已经在 Phase 1 尝试过了
|
||||
for (let i = 1; i < fullPriorityList.length; i++) {
|
||||
const source = fullPriorityList[i];
|
||||
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
|
||||
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId) && getLanguageType(a.animeTitle) === 'CN');
|
||||
items.forEach(item => secondaryCnCandidates.push(item));
|
||||
}
|
||||
|
||||
@@ -2282,7 +2319,6 @@ export async function applyMergeLogic(curAnimes) {
|
||||
|
||||
for (const tAnime of secondaryCnCandidates) {
|
||||
if (groupConsumedIds.has(tAnime.animeId)) continue;
|
||||
if (globalConsumedIds.has(tAnime.animeId)) continue;
|
||||
|
||||
const currentPriorityIdx = sourcePriorityMap.get(tAnime.source);
|
||||
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
|
||||
@@ -2305,7 +2341,7 @@ export async function applyMergeLogic(curAnimes) {
|
||||
// 处理剩余的所有资源(包含非 CN 资源以及 Phase 1/1.5 未匹配的 CN 资源)
|
||||
const remainingCandidates = [];
|
||||
fullPriorityList.forEach(source => {
|
||||
const items = curAnimes.filter(a => a.source === source && !globalConsumedIds.has(a.animeId));
|
||||
const items = curAnimes.filter(a => a.source === source && !groupConsumedIds.has(a.animeId));
|
||||
items.forEach(item => remainingCandidates.push(item));
|
||||
});
|
||||
|
||||
@@ -2315,7 +2351,6 @@ export async function applyMergeLogic(curAnimes) {
|
||||
|
||||
for (const pAnime of remainingCandidates) {
|
||||
if (groupConsumedIds.has(pAnime.animeId)) continue;
|
||||
if (globalConsumedIds.has(pAnime.animeId)) continue;
|
||||
|
||||
const currentPriorityIdx = sourcePriorityMap.get(pAnime.source);
|
||||
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
|
||||
@@ -2354,6 +2389,7 @@ export async function applyMergeLogic(curAnimes) {
|
||||
}
|
||||
}
|
||||
|
||||
// 将所有合法衍生出来的合并对象推入主列表
|
||||
if (newMergedAnimes.length > 0) {
|
||||
for (const anime of newMergedAnimes) addAnime(anime);
|
||||
curAnimes.unshift(...newMergedAnimes);
|
||||
@@ -2366,6 +2402,7 @@ export async function applyMergeLogic(curAnimes) {
|
||||
}
|
||||
}
|
||||
|
||||
// 最终清理:移除已被任意一组所消费的单源原始资源
|
||||
for (let i = curAnimes.length - 1; i >= 0; i--) {
|
||||
const item = curAnimes[i];
|
||||
if (item._isMerged || globalConsumedIds.has(item.animeId)) curAnimes.splice(i, 1);
|
||||
|
||||
Reference in new issue
Block a user