added manual matching on anime
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(/'/g, "'") // decodificar entidades HTML
|
||||
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
|
||||
.replace(/\s+/g, ' ') // normalizar espacios
|
||||
.trim();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
</svg>
|
||||
<span>MPV</span>
|
||||
</button>
|
||||
<button class="btn-icon-glass" id="manual-match-btn" title="Manual Match" style="display: none;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
||||
<div class="sd-bg"></div>
|
||||
<div class="sd-option active" id="opt-sub">Sub</div>
|
||||
@@ -97,6 +103,20 @@
|
||||
<select id="extension-select" class="glass-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="match-modal">
|
||||
<div class="modal-content download-settings-content">
|
||||
<button class="modal-close" id="close-match-modal">✕</button>
|
||||
<h2 class="modal-title">Manual Match</h2>
|
||||
|
||||
<div class="dl-section">
|
||||
<input type="text" id="match-search-input" class="glass-input" placeholder="Search in this extension..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="download-sections-wrapper">
|
||||
<div id="match-results-list" class="dl-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-frame">
|
||||
|
||||
@@ -709,7 +709,7 @@ body.stop-scrolling {
|
||||
MODAL DE DESCARGAS - REDISEÑO "GLASS"
|
||||
========================================= */
|
||||
|
||||
#download-modal {
|
||||
#download-modal, #match-modal {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -727,7 +727,7 @@ body.stop-scrolling {
|
||||
}
|
||||
|
||||
/* Estado visible activado por JS */
|
||||
#download-modal.show {
|
||||
#download-modal.show, #match-modal.show {
|
||||
display: flex !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
@@ -752,7 +752,8 @@ body.stop-scrolling {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#download-modal.show .download-settings-content {
|
||||
#download-modal.show .download-settings-content,
|
||||
#match-modal.show .download-settings-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@@ -916,4 +917,96 @@ body.stop-scrolling {
|
||||
background: #7c3aed; /* Un tono más oscuro del brand */
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--brand-color);
|
||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Reutilización para los resultados de búsqueda */
|
||||
.match-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.match-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.match-item img {
|
||||
width: 40px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.match-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.match-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Estado activo (seleccionado actualmente si quisieras marcarlo) */
|
||||
.match-item.active {
|
||||
border: 1px solid var(--brand-color);
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.btn-view-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-decoration: none;
|
||||
margin-left: auto; /* Empuja el botón a la derecha */
|
||||
}
|
||||
|
||||
.btn-view-source:hover {
|
||||
background: var(--brand-color);
|
||||
color: white;
|
||||
border-color: var(--brand-color-light);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Ajuste para que el texto no choque con el botón */
|
||||
.match-info {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user