added manual matching on anime

This commit is contained in:
2026-01-02 14:22:53 +01:00
parent a1d70193fa
commit f5cfa29b64
12 changed files with 646 additions and 64 deletions

View File

@@ -96,7 +96,7 @@ export async function searchInExtension(req: any, reply: FastifyReply) {
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
try {
const { animeId, episode, server, category, ext, source } = req.query;
const { animeId, episode, server, category, ext, source, extensionAnimeId } = req.query;
const extension = getExtension(ext);
if (!extension) return { error: "Extension not found" };
@@ -107,7 +107,8 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
animeId,
source,
server,
category
category,
extensionAnimeId
);
} catch (err) {
const error = err as Error;

View File

@@ -276,7 +276,6 @@ export async function getAnimeInfoExtension(ext: Extension | null, id: string):
if (!ext) return { error: "not found" };
const extName = ext.constructor.name;
const cached = await getCachedExtension(extName, id);
if (cached) {
try {
@@ -341,6 +340,7 @@ export async function searchAnimeInExtension(ext: Extension | null, name: string
averageScore: m.rating || m.score || null,
format: 'ANIME',
seasonYear: null,
url: m.url,
isExtensionResult: true,
}));
}
@@ -387,22 +387,41 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
startDate: { year: 0, month: 0, day: 0 }
}
});
if (!matches || matches.length === 0) return [];
const res = matches[0];
const normalizedQuery = normalize(query);
const scored = matches.map(match => {
const normalizedTitle = normalize(match.title);
const score = similarity(normalizedQuery, normalizedTitle);
let bonus = 0;
if (normalizedTitle === normalizedQuery) {
bonus = 0.5;
} else if (normalizedTitle.toLowerCase().includes(normalizedQuery.toLowerCase())) {
bonus = 0.5;
}
const finalScore = score + bonus;
return {
match,
score: finalScore
};
});
scored.sort((a, b) => b.score - a.score);
const bestMatches = scored.filter(s => s.score > 0.4);
if (bestMatches.length === 0) return [];
const res = bestMatches[0].match;
if (!res?.id) return [];
mediaId = res.id;
} else {
mediaId = query;
}
const chapterList = await ext.findEpisodes(mediaId);
if (!Array.isArray(chapterList)) return [];
const result: Episode[] = chapterList.map(ep => ({
id: ep.id,
number: ep.number,
@@ -421,25 +440,24 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
return [];
}
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise<StreamData> {
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string, extensionAnimeId?: string): Promise<StreamData> {
const providerName = extension.constructor.name;
const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`;
if (!extensionAnimeId) {
const cached = await getCache(cacheKey);
const cached = await getCache(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
try {
return JSON.parse(cached.result) as StreamData;
} catch (e) {
console.error(`[${providerName}] Error parsing cached stream data:`, e);
if (!isExpired) {
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
try {
return JSON.parse(cached.result) as StreamData;
} catch (e) {
console.error(`[${providerName}] Error parsing cached stream data:`, e);
}
}
} else {
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
}
}
@@ -448,13 +466,13 @@ export async function getStreamData(extension: Extension, episode: string, id: s
}
let episodes;
if (source === "anilist"){
const anime: any = await getAnimeById(id)
if (source === "anilist" && !extensionAnimeId) {
const anime: any = await getAnimeById(id);
episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji);
} else {
episodes = await extension.findEpisodes(extensionAnimeId ?? id);
}
else{
episodes = await extension.findEpisodes(id);
}
const targetEp = episodes.find(e => e.number === parseInt(episode));
if (!targetEp) {
@@ -466,4 +484,47 @@ export async function getStreamData(extension: Extension, episode: string, id: s
await setCache(cacheKey, streamData, CACHE_TTL_MS);
return streamData;
}
function similarity(s1: string, s2: string): number {
const str1 = normalize(s1);
const str2 = normalize(s2);
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.length === 0) return 1.0;
const editDistance = levenshteinDistance(longer, shorter);
return (longer.length - editDistance) / longer.length;
}
function levenshteinDistance(s1: string, s2: string): number {
const costs: number[] = [];
for (let i = 0; i <= s1.length; i++) {
let lastValue = i;
for (let j = 0; j <= s2.length; j++) {
if (i === 0) {
costs[j] = j;
} else if (j > 0) {
let newValue = costs[j - 1];
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
}
costs[j - 1] = lastValue;
lastValue = newValue;
}
}
if (i > 0) costs[s2.length] = lastValue;
}
return costs[s2.length];
}
function normalize(str: string): string {
return str
.toLowerCase()
.replace(/&#39;/g, "'") // decodificar entidades HTML
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
.replace(/\s+/g, ' ') // normalizar espacios
.trim();
}

View File

@@ -62,6 +62,7 @@ export interface ExtensionSearchOptions {
}
export interface ExtensionSearchResult {
url: string;
format: string;
headers: any;
id: string;
@@ -158,6 +159,7 @@ export interface WatchStreamQuery {
server?: string;
category?: string;
ext: string;
extensionAnimeId?: string;
}
export interface BookParams {

View File

@@ -22,6 +22,9 @@ const AnimePlayer = (function() {
let plyrInstance = null;
let hlsInstance = null;
let _manualExtensionId = null;
let _searchTimeout = null;
const els = {
wrapper: null,
playerWrapper: null,
@@ -41,7 +44,12 @@ const AnimePlayer = (function() {
dlAudioList: null,
dlSubsList: null,
dlConfirmBtn: null,
dlCancelBtn: null
dlCancelBtn: null,
manualMatchBtn: null,
matchModal: null,
matchInput: null,
matchList: null,
closeMatchModalBtn: null
};
function init(animeId, initialSource, isLocal, animeData) {
@@ -74,6 +82,32 @@ const AnimePlayer = (function() {
els.dlSubsList = document.getElementById('dl-subs-list');
els.dlConfirmBtn = document.getElementById('confirm-dl-btn');
els.dlCancelBtn = document.getElementById('cancel-dl-btn');
els.manualMatchBtn = document.getElementById('manual-match-btn');
els.matchModal = document.getElementById('match-modal');
els.matchInput = document.getElementById('match-search-input');
els.matchList = document.getElementById('match-results-list');
els.closeMatchModalBtn = document.getElementById('close-match-modal');
// Event Listeners para Manual Match
if (els.manualMatchBtn) els.manualMatchBtn.addEventListener('click', openMatchModal);
if (els.closeMatchModalBtn) els.closeMatchModalBtn.addEventListener('click', closeMatchModal);
// Cerrar modal al hacer click fuera
if (els.matchModal) {
els.matchModal.addEventListener('click', (e) => {
if (e.target === els.matchModal) closeMatchModal();
});
}
// Input de búsqueda con Debounce
if (els.matchInput) {
els.matchInput.addEventListener('input', (e) => {
clearTimeout(_searchTimeout);
_searchTimeout = setTimeout(() => {
executeMatchSearch(e.target.value);
}, 500); // Esperar 500ms tras dejar de escribir
});
}
const closeDlModalBtn = document.getElementById('close-download-modal');
@@ -138,6 +172,108 @@ const AnimePlayer = (function() {
loadExtensionsList();
}
function openMatchModal() {
if (!els.matchModal) return;
// Limpiar contenido previo
els.matchInput.value = '';
els.matchList.innerHTML = `<div style="padding:20px; text-align:center; color:#777;">Type to search in ${els.extSelect.value}...</div>`;
// 1. Mostrar el contenedor (para que el navegador calcule el layout)
els.matchModal.style.display = 'flex';
// 2. Pequeño delay o forzar reflow para que la transición de opacidad funcione
requestAnimationFrame(() => {
els.matchModal.classList.add('show');
});
setTimeout(() => els.matchInput.focus(), 100);
}
function closeMatchModal() {
if (!els.matchModal) return;
els.matchModal.classList.remove('show');
setTimeout(() => {
if (!els.matchModal.classList.contains('show')) {
els.matchModal.style.display = 'none';
}
}, 300);
}
async function executeMatchSearch(query) {
if (!query || query.trim().length < 2) return;
const ext = els.extSelect.value;
if (!ext || ext === 'local') return;
els.matchList.innerHTML = '<div class="spinner" style="margin: 20px auto;"></div>';
try {
const res = await fetch(`/api/search/${ext}?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderMatchResults(data.results || []);
} catch (e) {
console.error("Match Search Error:", e);
els.matchList.innerHTML = '<p style="color:#ef4444; text-align:center;">Error searching extension.</p>';
}
}
function renderMatchResults(results) {
els.matchList.innerHTML = '';
if (results.length === 0) {
els.matchList.innerHTML = '<p style="text-align:center; color:#999;">No results found.</p>';
return;
}
results.forEach(item => {
const div = document.createElement('div');
div.className = 'match-item dl-item';
const img = (item.coverImage && item.coverImage.large) ? item.coverImage.large : "/public/assets/placeholder.svg";
const title = item.title.english || item.title.romaji || item.title || 'Unknown';
const externalUrl = item.url || '#'; // El parámetro URL del JSON
div.innerHTML = `
<img src="${img}" alt="cover">
<div class="match-info">
<span class="match-title">${title}</span>
<span class="match-meta">${item.releaseDate || item.year || ''}</span>
</div>
${item.url ? `
<a href="${externalUrl}" target="_blank" class="btn-view-source" title="View Source">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
` : ''}
`;
div.onclick = (e) => {
if (e.target.closest('.btn-view-source')) return;
selectManualMatch(item);
};
els.matchList.appendChild(div);
});
}
function selectManualMatch(item) {
// 1. Guardar el ID de la extensión
_manualExtensionId = item.id;
console.log("Manual Match Selected:", _manualExtensionId, "for extension:", els.extSelect.value);
// 2. Cerrar modal
closeMatchModal();
// 3. Recargar el stream con el nuevo ID
loadStream();
}
async function openInMPV() {
if (!_rawVideoData) {
alert("No video loaded yet.");
@@ -579,6 +715,11 @@ const AnimePlayer = (function() {
if (shouldPlay && _currentEpisode > 0) loadStream();
return;
}
if (els.manualMatchBtn) {
// No mostrar en local, sí en extensiones
els.manualMatchBtn.style.display = (selectedExt === 'local') ? 'none' : 'flex';
}
_manualExtensionId = null;
setLoading("Loading Extension Settings...");
try {
@@ -690,14 +831,19 @@ const AnimePlayer = (function() {
const extParam = `&ext=${currentExt}`;
const realSource = _entrySource === 'local' ? 'anilist' : _entrySource;
const url =
`/api/watch/stream?animeId=${_animeId}` +
// AQUÍ AGREGAMOS EL PARÁMETRO OPCIONAL extensionAnimeId
let url = `/api/watch/stream?animeId=${_animeId}` +
`&episode=${_currentEpisode}` +
`&server=${encodeURIComponent(server)}` +
`&category=${_audioMode}` +
`${extParam}` +
`&source=${realSource}`;
// INYECCIÓN DEL ID MANUAL
if (_manualExtensionId) {
url += `&extensionAnimeId=${encodeURIComponent(_manualExtensionId)}`;
}
try {
const res = await fetch(url);
const data = await res.json();