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,11 +440,11 @@ 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);
if (cached) {
@@ -438,8 +457,7 @@ export async function getStreamData(extension: Extension, episode: string, id: s
} 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) {
@@ -467,3 +485,46 @@ 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();

View File

@@ -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">

View File

@@ -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);
}
@@ -917,3 +918,95 @@ body.stop-scrolling {
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;
}

View File

@@ -87,7 +87,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" };
@@ -98,7 +98,8 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
animeId,
source,
server,
category
category,
extensionAnimeId
);
} catch (err) {
const error = err as Error;

View File

@@ -340,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,
}));
}
@@ -439,11 +440,11 @@ 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);
if (cached) {
@@ -456,8 +457,7 @@ export async function getStreamData(extension: Extension, episode: string, id: s
} catch (e) {
console.error(`[${providerName}] Error parsing cached stream data:`, e);
}
} else {
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
}
}
}
@@ -466,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) {

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();

View File

@@ -61,13 +61,15 @@
<div class="header-right">
<button class="btn-icon-glass" id="download-btn" title="Download Episode" style="display: none;">
</button>
<div class="settings-group">
<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
<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="settings-group">
<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>
@@ -78,6 +80,21 @@
<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">

View File

@@ -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);
}
@@ -917,3 +918,95 @@ body.stop-scrolling {
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;
}