#!/usr/bin/env node /** * E2E test: exercises the fixed match pipeline against real fansub files. * * For each sample we: * 1) Compute MD5 of first 16MB (same as client's md5.worker.js) * 2) Call dandanplay /api/v2/match (matches server's matchCombined) * 3) Apply our controller Phase 1 gate (isMatched OR title-loose-match) * 4) Fetch /api/v2/bangumi/{animeId} and run buildEpisodeMap * 5) Print whether episode map is non-empty (i.e. danmaku would load) * * Covers every folder in /Volumes/T7 Shield/DandanPlay foler, including: * - S2/S3/S4 continuation seasons (raw numbers offset: 13-24, 29-38, etc.) * - Fansubs that renumber continuation seasons 1..12 (Oshi no Ko S3 fixture) * - S1 baseline (pure-number level-1 match) * - Triple-bracket naming ([Group][Title][Ep]) * - Movies */ require('dotenv').config({ path: require('path').join(__dirname, '../server/.env') }); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { buildEpisodeMap } = require('../server/utils/episodeMap'); const TEST_DIR = '/Volumes/T7 Shield/DandanPlay foler'; const EXTRA_DIR = '/Users/lawrence_li/Movies'; const APP_ID = process.env.DANDANPLAY_APP_ID; const APP_SECRET = process.env.DANDANPLAY_APP_SECRET; const BASE_URL = 'https://api.dandanplay.net'; const headers = { 'X-AppId': APP_ID, 'X-AppSecret': APP_SECRET, 'Content-Type': 'application/json', }; function normalizeTitle(s) { return String(s || '') .toLowerCase() .replace(/[\s\[\]【】()《》「」『』,.\-_~!@#$%^&*+=|\\/:;?'"]/g, ''); } function titleLooselyMatchesKeyword(animeTitle, keyword) { const a = normalizeTitle(animeTitle); const k = normalizeTitle(keyword); if (!a || !k) return false; return a.includes(k) || k.includes(a); } function hash16MB(filePath) { const fd = fs.openSync(filePath, 'r'); const chunkSize = 16 * 1024 * 1024; const stat = fs.fstatSync(fd); const size = Math.min(chunkSize, stat.size); const buf = Buffer.alloc(size); fs.readSync(fd, buf, 0, size, 0); fs.closeSync(fd); return { hash: crypto.createHash('md5').update(buf).digest('hex'), fileSize: stat.size }; } function extractKeyword(fileName) { let s = fileName.replace(/\.(mkv|mp4)$/i, ''); // Strip ALL leading [tags] (some files have [Group][Sub] double prefix) while (/^\[[^\]]+\]\s*/.test(s)) s = s.replace(/^\[[^\]]+\]\s*/, ''); // Triple-bracket: [Comicat][Title][13] → title is the first surviving [...] const triple = s.match(/^\[([^\]]+)\]/); if (triple) s = triple[1]; // Strip trailing tags like [1080P][XXX] s = s.replace(/\s*\[[^\]]+\].*$/g, ''); // Strip episode markers s = s.replace(/\s*-\s*\[?[\d\-\s]+\]?\s*$/, ''); s = s.replace(/\s*-\s*\d+v?\d*\s*$/i, ''); s = s.replace(/\s*\(\d{4}\)\s*/, ' '); return s.trim(); } function extractEpisode(fileName) { const m = fileName.match(/\s-\s(\d{1,3})v?\d*\s/) || fileName.match(/\]\[(\d{1,3})\]/) || fileName.match(/\[(\d{1,3})\]/); return m ? parseInt(m[1], 10) : 1; } async function matchCombined(fileName, fileHash, fileSize) { const body = { fileName }; if (fileHash) body.fileHash = fileHash; if (fileSize) body.fileSize = fileSize; const res = await fetch(`${BASE_URL}/api/v2/match`, { method: 'POST', headers, body: JSON.stringify(body), }); if (!res.ok) return null; const data = await res.json(); if (!data.matches?.length) return { isMatched: false }; const best = data.matches[0]; return { isMatched: !!data.isMatched, animeId: best.animeId, animeTitle: best.animeTitle, episodeId: best.episodeId, episodeTitle: best.episodeTitle, }; } async function fetchEpisodes(animeId) { const res = await fetch(`${BASE_URL}/api/v2/bangumi/${animeId}`, { headers }); if (!res.ok) return null; const data = await res.json(); if (!data.bangumi) return null; return (data.bangumi.episodes || []).map(ep => ({ dandanEpisodeId: ep.episodeId, title: ep.episodeTitle, rawEpisodeNumber: ep.episodeNumber || '', number: /^\d+$/.test(ep.episodeNumber) ? parseInt(ep.episodeNumber, 10) : null, })); } async function testFile(filePath, requestedEp, overrideKeyword) { const fileName = path.basename(filePath); const keyword = overrideKeyword || extractKeyword(fileName); const ep = requestedEp || extractEpisode(fileName); const result = { fileName, keyword, ep, verdict: '', detail: '' }; try { const { hash, fileSize } = hash16MB(filePath); const combined = await matchCombined(fileName, hash, fileSize); result.apiIsMatched = combined?.isMatched ?? false; result.apiAnimeTitle = combined?.animeTitle ?? null; result.apiAnimeId = combined?.animeId ?? null; const accept = combined && ( combined.isMatched || (combined.animeId && titleLooselyMatchesKeyword(combined.animeTitle, keyword)) ); result.phase1Accepted = !!accept; if (!accept) { result.verdict = 'PHASE1_REJECTED'; return result; } const episodes = await fetchEpisodes(combined.animeId); if (!episodes?.length) { result.verdict = 'NO_EPISODES'; return result; } result.rawRange = `${episodes[0].rawEpisodeNumber}..${episodes[episodes.length - 1].rawEpisodeNumber}`; result.totalEps = episodes.length; const map = buildEpisodeMap(episodes, [ep]); const mapped = map[ep]; if (!mapped) { result.verdict = 'NO_MAP'; return result; } result.mappedId = mapped.dandanEpisodeId; result.mappedTitle = mapped.title; result.verdict = 'OK'; } catch (err) { result.verdict = 'ERROR'; result.detail = err.message; } return result; } // Every folder/file present on the test drive, grouped by pattern. const SAMPLES = [ // ─── S2/S3/S4 continuation seasons (the bug's primary target) ──────── { label: 'Enen no Shouboutai S3', dir: '[LoliHouse] Enen no Shouboutai S3 - [01-12] [WebRip 1080p HEVC-10bit AAC SRTx2]', file: '[LoliHouse] Enen no Shouboutai S3 - 05 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv', keyword: 'Enen no Shouboutai', ep: 5 }, { label: 'Kakkou no Iinazuke S2', dir: '[LoliHouse] Kakkou no Iinazuke S2 [01-12][WebRip 1080p HEVC-10bit AAC]', file: '[LoliHouse] Kakkou no Iinazuke S2 - 03 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv', keyword: 'Kakkou no Iinazuke', ep: 3 }, { label: 'Yofukashi no Uta S2', dir: '[LoliHouse] Yofukashi no Uta S2 [01-12][WebRip 1080p HEVC-10bit AAC]', file: '[LoliHouse] Yofukashi no Uta S2 - 02v2 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv', keyword: 'Yofukashi no Uta', ep: 2 }, { label: 'Frieren 第二季 (raw 29-38)', file: '[ANi] 葬送的芙莉蓮 第二季 - [29-38][1080P][Baha][WEB-DL][AAC AVC][CHT].mp4', keyword: '葬送的芙莉蓮', ep: 31 }, { label: 'Dan Da Dan S2 (raw 13-24)', dir: '[Skymoon-Raws] DAN DA DAN - [13-24][CHT][SRT][1080p][AVC AAC]', keyword: 'DAN DA DAN', ep: 13 }, { label: 'Spy x Family S3 (raw 38-)', dir: '[云光字幕组] Spy x Family Season 3 [01-12]', keyword: 'Spy x Family', ep: 38 }, { label: 'Tate no Yuusha S4', dir: '[Tate no Yuusha no Nariagari S4][01-12][BIG5][720P]', keyword: 'Tate no Yuusha no Nariagari', ep: 1 }, { label: 'Kaijuu 8-gou S2 (raw 13-23)', dir: '[LoliHouse] Kaijuu 8-gou [13-23+SP][WebRip 1080p HEVC-10bit AAC]', keyword: 'Kaijuu 8-gou', ep: 13 }, { label: 'Shoushimin Series S2 (raw 11-20)', dir: '[MingY] Shoushimin Series [11-20][WebRip][JPCN]', keyword: 'Shoushimin Series', ep: 11 }, { label: 'Uma Musume Cinderella Gray S2 (raw 14-23)', dir: '[OguriClub&S1YURICON] Umamusume Cinderella Gray[14-23][1080p][WebRip][HEVC_AAC][CHS_JP]', keyword: 'Umamusume Cinderella Gray', ep: 14 }, // ─── S1 baselines (should hit level-1 pure-number match) ───────────── { label: 'Sono Bisque Doll (raw 13-24)', file: '[Comicat][Sono Bisque Doll wa Koi o Suru][13][1080P][GB&JP][MP4].mp4', keyword: 'Sono Bisque Doll wa Koi o Suru', ep: 13 }, { label: 'Kaoru Hana wa Rin to Saku', file: '[Nekomoe kissaten][Kaoru Hana wa Rin to Saku][03][1080p][JPSC].mp4', keyword: 'Kaoru Hana wa Rin to Saku', ep: 3 }, { label: 'Hibi wa Sugiredo Meshi Umashi', dir: '[LoliHouse] Hibi wa Sugiredo Meshi Umashi -[1- 12] [WebRip 1080p HEVC-10bit AAC ASSx2]', keyword: 'Hibi wa Sugiredo Meshi Umashi', ep: 1 }, { label: 'Katainaka no Ossan, Kensei ni Naru', dir: '[LoliHouse] Katainaka no Ossan, Kensei ni Naru - [01-12] [WebRip 1080p HEVC-10bit AAC SRTx2]', keyword: 'Katainaka no Ossan', ep: 1 }, { label: 'Uma Musume Cinderella Gray S1', dir: '[LoliHouse] Uma Musume Cinderella Gray - [1-13] [WebRip 1080p HEVC-10bit AAC SRTx2]', keyword: 'Uma Musume Cinderella Gray', ep: 1 }, { label: 'One Punch Man (2025)', dir: '[Sakurato] One Punch Man (2025) [01-12][AVC-8bit 1080p AAC][CHS]', keyword: 'One Punch Man', ep: 1 }, { label: 'LAZARUS', dir: '[Nekomoe kissaten][LAZARUS][01-13][1080p][JPSC]', keyword: 'LAZARUS', ep: 1 }, { label: 'Zatsu Tabi', dir: '[Nekomoe kissaten][Zatsu Tabi][01-12][1080p][JPSC]', keyword: 'Zatsu Tabi', ep: 1 }, { label: 'mono女孩', dir: '[ANi] mono女孩 - [1-12] [1080P][Baha][WEB-DL][AAC AVC][CHT]', keyword: 'mono女孩', ep: 1 }, { label: 'Can a Boy-Girl Friendship Survive', dir: '[KTXP][Can_a_Boy-Girl_Friendship_Survive][1-12][GB_CN][HEVC_opus][1080p]', keyword: 'Can a Boy-Girl Friendship Survive', ep: 1 }, { label: 'Seishun Buta Yarou Santa Claus', file: '[Skymoon-Raws] Seishun Buta Yarou wa Santa Claus no Yume wo Minai - 01 [ViuTV][WEB-DL][CHT][SRT][1080p][AVC AAC].mkv', keyword: 'Seishun Buta Yarou', ep: 1 }, { label: 'Mobile Suit Gundam GQuuuuuuX', dir: '[SweetSub] Mobile Suit Gundam GQuuuuuuX [01-12][WebRip][1080P][AVC 8bit][CHS]', keyword: 'Gundam GQuuuuuuX', ep: 1 }, { label: 'Oniichan ha Oshimai!', dir: '[SweetSub] Oniichan ha Oshimai! [01-12][BDRip][1080P][AVC 8bit][CHT]', keyword: 'Oniichan ha Oshimai', ep: 1 }, // ─── Movies ────────────────────────────────────────────────────────── { label: 'Demon Slayer Infinity Castle', file: '鬼灭之刃:无限城篇 第一章 猗窝座再袭.Demon.Slayer.Kimetsu.no.Yaiba.The.Movie.Infinity.Castle.2025.1080p.WEBRip.x265.AAC-PorterRAWS.mkv', keyword: '鬼灭之刃 无限城', ep: 1 }, { label: 'Cosmic Princess Kaguya (Movie)', file: 'Cosmic.Princess.Kaguya.2026.1080p.NF.WEB-DL.JPN.DDP5.1.H.264-shenghuo2.ZH.mkv', keyword: 'Cosmic Princess Kaguya', ep: 1 }, { label: '超时空辉夜姬 Movie', file: '[Pre-S&三明治摸鱼部] 超时空辉夜姬 Cosmic Princess Kaguya [Movie][WebRip 1080P AVC 8Bit AAC MP4][简日内嵌][全歌曲特效][V2].mp4', keyword: '超时空辉夜姬', ep: 1 }, // ─── Original bug fixture (Oshi no Ko S3, raw 25..35 + C1/C2/C3) ───── { label: 'Oshi no Ko S3 E11 (original bug)', absPath: path.join(EXTRA_DIR, '[LoliHouse] Oshi no Ko S3 - 11 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv'), keyword: '我推的孩子 第三季', ep: 11 }, ]; async function testManualPick(label, animeId, requestedEps) { const episodes = await fetchEpisodes(animeId); if (!episodes?.length) return { label, animeId, verdict: 'NO_EPISODES' }; const rawRange = `${episodes[0].rawEpisodeNumber}..${episodes[episodes.length - 1].rawEpisodeNumber}`; const map = buildEpisodeMap(episodes, requestedEps); return { label, animeId, rawRange, totalEps: episodes.length, requestedEps, map, verdict: Object.keys(map).length === requestedEps.length ? 'OK' : 'PARTIAL', }; } function resolveSamplePath(s) { if (s.absPath) return s.absPath; if (s.dir) { const dir = path.join(TEST_DIR, s.dir); if (!fs.existsSync(dir)) return null; const picks = fs.readdirSync(dir) .filter(f => /\.(mkv|mp4)$/i.test(f) && !f.startsWith('.')) .sort(); if (s.file) return path.join(dir, s.file); return picks.length ? path.join(dir, picks[0]) : null; } return path.join(TEST_DIR, s.file); } async function main() { const results = []; for (const s of SAMPLES) { const fullPath = resolveSamplePath(s); if (!fullPath || !fs.existsSync(fullPath)) { results.push({ label: s.label, fileName: s.file || s.dir, verdict: 'FILE_MISSING' }); continue; } let actualPath = fullPath; if (fs.statSync(actualPath).isDirectory()) { const picks = fs.readdirSync(actualPath) .filter(f => /\.(mkv|mp4)$/i.test(f) && !f.startsWith('.')) .sort(); if (!picks.length) { results.push({ label: s.label, fileName: path.basename(actualPath), verdict: 'NO_VIDEO_IN_DIR' }); continue; } actualPath = path.join(actualPath, picks[0]); } process.stderr.write(`Testing: ${s.label} → ${path.basename(actualPath)}\n`); const r = await testFile(actualPath, s.ep, s.keyword); r.label = s.label; results.push(r); } console.log('\n=== RESULTS ===\n'); for (const r of results) { const v = r.verdict === 'OK' ? '✅' : r.verdict === 'FILE_MISSING' ? '⏭️' : '❌'; console.log(`${v} ${r.label}`); console.log(` file: ${r.fileName}`); if (r.keyword) console.log(` keyword: "${r.keyword}" ep=${r.ep}`); if (r.apiAnimeTitle !== undefined) { console.log(` API: isMatched=${r.apiIsMatched} candidate="${r.apiAnimeTitle}" (id=${r.apiAnimeId})`); } if (r.phase1Accepted !== undefined) { console.log(` phase 1: ${r.phase1Accepted ? 'ACCEPTED' : 'REJECTED'}`); } if (r.rawRange) console.log(` eps: raw ${r.rawRange} (${r.totalEps} eps)`); if (r.mappedId) console.log(` mapped: ep ${r.ep} → ${r.mappedId} "${r.mappedTitle}"`); console.log(` verdict: ${r.verdict}${r.detail ? ' (' + r.detail + ')' : ''}`); console.log(''); } const attempted = results.filter(r => r.verdict !== 'FILE_MISSING'); const ok = attempted.filter(r => r.verdict === 'OK').length; const rejected = attempted.filter(r => r.verdict === 'PHASE1_REJECTED').length; const noMap = attempted.filter(r => r.verdict === 'NO_MAP').length; const missing = results.filter(r => r.verdict === 'FILE_MISSING').length; console.log(`Summary: ${ok}/${attempted.length} OK, ${rejected} phase-1 rejected, ${noMap} no-map, ${missing} file-missing\n`); // Manual-pick path: exercises client-side selectManual → buildEpisodeMap console.log('=== MANUAL PICK PATH (Oshi no Ko S3, id=18901) ===\n'); const manualTests = [ { label: 'Oshi no Ko S3 E11 single pick', animeId: 18901, eps: [11] }, { label: 'Oshi no Ko S3 all 11 eps batched', animeId: 18901, eps: [1,2,3,4,5,6,7,8,9,10,11] }, ]; for (const t of manualTests) { const r = await testManualPick(t.label, t.animeId, t.eps); console.log(`${t.label}`); if (r.rawRange) console.log(` raw range: ${r.rawRange} (${r.totalEps} eps)`); for (const ep of t.eps) { const m = r.map?.[ep]; console.log(` ep ${String(ep).padStart(2)} → ${m ? `${m.dandanEpisodeId} "${m.title}"` : 'UNMAPPED'}`); } console.log(` VERDICT: ${r.verdict}\n`); } } main().catch(err => { console.error(err); process.exit(1); });