Files
Agora/public/app.js
T
2026-09-15 21:56:10 +02:00

1839 lines
91 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
// --- INIT ICONS ---
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// --- UTILS ---
const dom = (id) => document.getElementById(id);
const show = (el) => el && el.classList.remove('hidden');
const hide = (el) => el && el.classList.add('hidden');
const showToast = (msg) => {
const t = dom('toast');
if (!t) return;
t.textContent = msg;
show(t);
setTimeout(() => hide(t), 3000);
};
window.showToast = showToast;
const showConfirmModal = (title, message, options = {}) => {
return new Promise((resolve) => {
const confirmText = options.confirmText || 'Confirmer';
const cancelText = options.cancelText || 'Annuler';
const isDestructive = options.isDestructive !== false;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '30000';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 400px; text-align: center; animation: modalFadeIn 0.2s ease-out;">
<div style="margin-bottom: 1rem; color: ${isDestructive ? 'var(--primary)' : '#60a5fa'};">
<i data-lucide="${isDestructive ? 'alert-triangle' : 'help-circle'}" style="width: 48px; height: 48px; margin: 0 auto;"></i>
</div>
<h3 style="margin-bottom: 0.75rem; font-size: 1.25rem; font-weight: 700; color: white;">${title}</h3>
<p style="font-size: 0.9rem; color: var(--text-sec); margin-bottom: 1.5rem; line-height: 1.4;">${message}</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button id="confirm-yes-btn" class="${isDestructive ? 'btn-primary' : 'btn-success'}" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px;">
${confirmText}
</button>
<button id="confirm-no-btn" class="btn-action" style="width: 100%; background: transparent; border: 1px solid var(--border); color: var(--text-sec); justify-content: center; display: flex; align-items: center; padding: 12px;">
${cancelText}
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
if (window.lucide) {
window.lucide.createIcons({ root: overlay });
}
const cleanUp = (value) => {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease';
setTimeout(() => {
overlay.remove();
}, 200);
resolve(value);
};
overlay.querySelector('#confirm-yes-btn').onclick = () => cleanUp(true);
overlay.querySelector('#confirm-no-btn').onclick = () => cleanUp(false);
overlay.onclick = (e) => {
if (e.target === overlay) cleanUp(false);
};
});
};
const updateSiteStatusUI = (isOffline, message) => {
let banner = dom('offline-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'offline-banner';
banner.style = "background: #e50914; color: white; text-align: center; padding: 10px; font-weight: 700; position: sticky; top: 0; z-index: 9999; display: none; font-size: 0.9rem;";
document.body.prepend(banner);
}
if (isOffline) {
banner.textContent = `⚠️ SITE SOURCE INDISPONIBLE : ${message || 'Vérification en cours...'}`;
banner.style.display = 'block';
} else {
banner.style.display = 'none';
}
};
const apiCall = async (endpoint, method = 'GET', body = null) => {
if (!endpoint.startsWith('/api')) {
endpoint = endpoint.startsWith('/') ? `/api${endpoint}` : `/api/${endpoint}`;
}
try {
const opts = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(endpoint, opts);
const text = await res.text();
if (!res.ok) {
let err = `Erreur ${res.status}`;
try { err = JSON.parse(text).error || err; } catch (e) { }
throw new Error(err);
}
return text ? JSON.parse(text) : {};
} catch (e) {
console.error(`API ${endpoint}:`, e);
throw e;
}
};
// --- STATE & LOOPS MANAGER ---
const state = {
downloadInterval: null,
trendingData: { films: [], series: [], recent: [] },
activeSources: [],
availableSources: [], // Dynamically populated from /status
sourceLabels: {},
};
// --- GESTION INTELLIGENTE DES BOUCLES ---
const getPref = (key, defaultValue) => {
if (state.currentUser && state.currentUser.preferences && state.currentUser.preferences[key] !== undefined) {
return state.currentUser.preferences[key];
}
const local = localStorage.getItem(key);
if (local !== null) return local === 'true' ? true : (local === 'false' ? false : local);
return defaultValue;
};
const setPref = async (key, value) => {
localStorage.setItem(key, value);
if (state.currentUser) {
if (!state.currentUser.preferences) state.currentUser.preferences = {};
state.currentUser.preferences[key] = value;
}
try {
await apiCall('/api/preferences', 'POST', { key, value });
} catch (e) {
console.error('Erreur sauvegarde pref', e);
}
};
// 1. Gestion Téléchargements (Actif seulement sur l'onglet)
const startDownloadLoop = () => {
if (state.downloadInterval) clearInterval(state.downloadInterval);
loadDownloads(); // Appel immédiat
state.downloadInterval = setInterval(loadDownloads, 5000); // Mise à jour toutes les 5s
console.log("Flux Téléchargement : ACTIVÉ");
};
// La boucle des téléchargements a été déplacée à la fin du fichier.
// --- LOGIN --- (handled in login.ejs inline script, not here)
const checkSession = async () => {
try {
const res = await apiCall('/check-session');
if (res.isLoggedIn) {
// Stocker les infos user pour usage dans l'app
state.currentUser = res.user || null;
if (dom('login-form')) {
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
const savedPage = localStorage.getItem('defaultPage') || '/trending';
window.location.href = allowedPages.includes(savedPage) ? savedPage : '/trending';
} else {
initApp();
}
} else {
if (!dom('login-form')) {
window.location.href = '/login';
}
}
} catch (e) { }
};
const initApp = async () => {
hide(dom('login-overlay'));
show(dom('app-container'));
const getCookie = (name) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
};
const showMustChangePasswordOverlay = () => {
const existing = document.getElementById('change-password-overlay');
if (existing) return;
const overlay = document.createElement('div');
overlay.id = 'change-password-overlay';
overlay.innerHTML = `
<div class="change-password-box">
<h2>Sécurité requise</h2>
<p>Votre compte utilise un mot de passe temporaire ou réinitialisé. Vous devez définir un nouveau mot de passe sécurisé pour continuer.</p>
<div class="password-requirements" style="text-align: left;">
<strong>Exigences du mot de passe :</strong>
<ul style="margin-top: 8px;">
<li id="req-length" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins 8 caractères</li>
<li id="req-upper" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins une majuscule (A-Z)</li>
<li id="req-number" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un chiffre (0-9)</li>
<li id="req-special" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un caractère spécial</li>
<li id="req-match" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Mots de passe identiques</li>
</ul>
</div>
<form id="force-change-pw-form" style="margin-top: 1rem;">
<div style="margin-bottom: 1rem; text-align: left;">
<label for="force-new-pw" style="display: block; margin-bottom: 6px;">Nouveau mot de passe</label>
<div class="password-container">
<input type="password" id="force-new-pw" required autocomplete="new-password">
<button type="button" class="toggle-password" tabindex="-1">
<i data-lucide="eye" class="eye-icon"></i>
</button>
</div>
</div>
<div style="margin-bottom: 1.25rem; text-align: left;">
<label for="force-confirm-pw" style="display: block; margin-bottom: 6px;">Confirmer le mot de passe</label>
<div class="password-container">
<input type="password" id="force-confirm-pw" required autocomplete="new-password">
<button type="button" class="toggle-password" tabindex="-1">
<i data-lucide="eye" class="eye-icon"></i>
</button>
</div>
</div>
<div id="force-pw-error" style="color: #ef4444; font-size: 0.85rem; display: none; font-weight: 600; margin-bottom: 1rem; text-align: left;"></div>
<button type="submit" id="btn-force-pw-submit" class="btn-primary" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px;" disabled>
Mettre à jour le mot de passe
</button>
<button type="button" id="btn-force-logout" class="btn-action" style="width: 100%; background: transparent; border: 1px solid var(--border); color: var(--text-sec); margin-top: 0.5rem; justify-content: center; display: flex; align-items: center; gap: 8px;">
Se déconnecter
</button>
</form>
</div>
`;
document.body.appendChild(overlay);
if (window.lucide) {
window.lucide.createIcons({ root: overlay });
}
const newPwInput = overlay.querySelector('#force-new-pw');
const confirmPwInput = overlay.querySelector('#force-confirm-pw');
const form = overlay.querySelector('#force-change-pw-form');
const errorDiv = overlay.querySelector('#force-pw-error');
const submitBtn = overlay.querySelector('#btn-force-pw-submit');
const logoutBtn = overlay.querySelector('#btn-force-logout');
const validateInputs = () => {
if (!window.AuthHelpers) return;
const val = newPwInput.value;
const confirmVal = confirmPwInput.value;
const statuses = AuthHelpers.validateComplexity(val, confirmVal);
AuthHelpers.updateRequirementsUI(overlay, statuses);
submitBtn.disabled = !statuses.allValid;
};
newPwInput.addEventListener('input', validateInputs);
confirmPwInput.addEventListener('input', validateInputs);
if (window.AuthHelpers) {
AuthHelpers.initPasswordToggles(overlay);
}
logoutBtn.onclick = async () => {
try {
await apiCall('/logout', 'POST');
window.location.href = '/login';
} catch (e) {
window.location.href = '/login';
}
};
form.onsubmit = async (e) => {
e.preventDefault();
errorDiv.style.display = 'none';
errorDiv.textContent = '';
try {
const res = await apiCall('/change-password', 'POST', { newPassword: newPwInput.value });
if (res.success) {
showToast('✓ Mot de passe mis à jour avec succès !');
overlay.remove();
if (state.currentUser) {
state.currentUser.mustChangePassword = false;
}
}
} catch (err) {
errorDiv.textContent = err.message || "Une erreur est survenue.";
errorDiv.style.display = 'block';
}
};
};
// --- Forced Password Change Checker ---
if (state.currentUser && state.currentUser.mustChangePassword) {
showMustChangePasswordOverlay();
return; // Bloquer la suite de l'initialisation de l'application
}
// --- Auth Error Toast Checker ---
const authError = getCookie('authError');
if (authError) {
// Delete the cookie
document.cookie = "authError=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
showToast(decodeURIComponent(authError).replace(/\+/g, ' '));
}
// --- JDownloader Toggle ---
const toggleJd = document.getElementById('toggle-jd');
if (toggleJd) {
toggleJd.checked = getPref('useJD', false);
toggleJd.addEventListener('change', (e) => setPref('useJD', e.target.checked));
}
// --- Default Preferred Page ---
const selectDefaultPage = document.getElementById('select-default-page');
if (selectDefaultPage) {
const setCookie = (name, value, days = 365) => {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/`;
};
const savedPage = getPref('defaultPage', '/trending');
selectDefaultPage.value = savedPage;
// Sync cookie
setCookie('defaultPage', savedPage);
selectDefaultPage.addEventListener('change', (e) => {
const newPage = e.target.value;
setCookie('defaultPage', newPage);
setPref('defaultPage', newPage);
showToast('Page par défaut mise à jour');
});
}
// --- Source Detection & Dynamic Options ---
try {
const statusData = await apiCall('/status');
state.activeSources = statusData.activeSources || [];
state.availableSources = statusData.availableSources || [];
state.sourceLabels = statusData.sourceLabels || {};
renderSourcesUI();
updateLocalDbFiltersVisibility();
} catch(e) { console.error('Erreur détection source:', e); }
loadTrending();
lucide.createIcons();
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
radio.addEventListener('change', renderTrending);
});
document.querySelectorAll('input[name="recent-type"]').forEach(radio => {
radio.addEventListener('change', renderRecent);
});
// --- HEARTBEAT : Détection source down + Refresh Tendances ---
let wasOffline = false;
const checkSourceStatus = async () => {
try {
const s = await apiCall('/status');
updateSiteStatusUI(s.isOffline, s.message);
state.activeSources = s.activeSources || [];
state.sourceLabels = s.sourceLabels || {};
const searchInput = dom('search-input');
const searchBtn = dom('btn-search-trigger');
if (searchInput) searchInput.disabled = s.isOffline;
if (searchBtn) searchBtn.disabled = s.isOffline;
if (!s.isOffline && (wasOffline || (!state.trendingData.films.length && !state.trendingData.series.length))) {
console.log('[Heartbeat] Site source en ligne, rechargement des tendances...');
loadTrending();
}
wasOffline = s.isOffline;
updateLocalDbFiltersVisibility();
} catch (e) {
wasOffline = true;
updateSiteStatusUI(true, 'Connexion au serveur perdue...');
}
};
setInterval(checkSourceStatus, 30000);
// ========================= ADMIN USER MANAGEMENT =========================
const adminUsersList = dom('admin-users-list');
if (adminUsersList && state.currentUser && state.currentUser.role === 'admin') {
const loadAdminUsers = async () => {
try {
const users = await apiCall('/admin/users');
adminUsersList.innerHTML = '';
users.forEach(user => {
const isCurrentUser = state.currentUser && state.currentUser.username === user.username;
const roleColor = user.role === 'admin' ? 'var(--primary)' : '#22c55e';
const roleLabel = user.role === 'admin' ? 'Admin' : 'Utilisateur';
const item = document.createElement('div');
item.className = 'admin-user-item';
item.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px;';
item.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 36px; height: 36px; border-radius: 50%; background: ${user.role === 'admin' ? 'rgba(229,9,20,0.15)' : 'rgba(34,197,94,0.15)'}; display: flex; align-items: center; justify-content: center;">
<i data-lucide="${user.role === 'admin' ? 'shield' : 'user'}" style="width: 16px; height: 16px; color: ${roleColor};"></i>
</div>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${user.username}${isCurrentUser ? ' <span style="font-size: 0.7rem; color: var(--text-sec);">(vous)</span>' : ''}</div>
<div style="font-size: 0.75rem; color: ${roleColor}; font-weight: 500;">${roleLabel}</div>
</div>
</div>
<div style="display: flex; gap: 6px;">
<button class="admin-reset-pw-btn" data-id="${user.id}" data-name="${user.username}" title="Réinitialiser le mot de passe" style="background: rgba(96,165,250,0.2); border: 1px solid rgba(96,165,250,0.4); color: #60a5fa; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">
<i data-lucide="key" style="width: 14px; height: 14px;"></i>
</button>
${!isCurrentUser ? `<button class="admin-delete-user-btn" data-id="${user.id}" data-name="${user.username}" title="Supprimer" style="background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">
<i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>
</button>` : ''}
</div>
`;
adminUsersList.appendChild(item);
});
// Re-init Lucide icons for the newly added elements
if (typeof lucide !== 'undefined') lucide.createIcons();
// Bind delete buttons
document.querySelectorAll('.admin-delete-user-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const userId = btn.dataset.id;
const userName = btn.dataset.name;
const confirmed = await showConfirmModal(
"Supprimer l'utilisateur",
`Êtes-vous sûr de vouloir supprimer définitivement l'utilisateur "${userName}" ? Cette action est irréversible.`,
{ confirmText: 'Supprimer', cancelText: 'Annuler', isDestructive: true }
);
if (!confirmed) return;
try {
await apiCall(`/admin/users/${userId}`, 'DELETE');
showToast(`Utilisateur "${userName}" supprimé.`);
loadAdminUsers();
} catch (err) {
showToast('Erreur: ' + err.message);
}
});
});
// Bind reset password buttons
document.querySelectorAll('.admin-reset-pw-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const userId = btn.dataset.id;
const userName = btn.dataset.name;
const confirmed = await showConfirmModal(
"Réinitialiser le mot de passe",
`Voulez-vous réinitialiser le mot de passe de l'utilisateur "${userName}" ? Un nouveau mot de passe aléatoire temporaire sera généré.`,
{ confirmText: 'Réinitialiser', cancelText: 'Annuler', isDestructive: false }
);
if (!confirmed) return;
try {
const result = await apiCall(`/admin/users/${userId}/reset-password`, 'POST');
if (result.generatedPassword) {
showPasswordModal(result.generatedPassword);
}
} catch (err) {
showToast('Erreur: ' + err.message);
}
});
});
} catch (err) {
console.error('[Admin] Erreur chargement users:', err);
}
};
// Show password modal helper
const showPasswordModal = (password) => {
const modal = dom('admin-password-modal');
const pwDisplay = dom('admin-generated-password');
if (!modal || !pwDisplay) return;
pwDisplay.textContent = password;
show(modal);
modal.style.display = ''; // Clear inline styles if any
const copyBtn = dom('admin-copy-password-btn');
const closeBtn = dom('admin-close-modal-btn');
const closeModal = () => { hide(modal); };
if (copyBtn) {
copyBtn.onclick = () => {
navigator.clipboard.writeText(password).then(() => {
copyBtn.textContent = '✓ Copié !';
setTimeout(() => { copyBtn.textContent = 'Copier le mot de passe'; }, 2000);
}).catch(() => {
showToast('Erreur clipboard. Sélectionnez et copiez manuellement.');
});
};
}
if (closeBtn) closeBtn.onclick = closeModal;
};
// Add user button
const addUserBtn = dom('admin-add-user-btn');
if (addUserBtn) {
addUserBtn.addEventListener('click', async () => {
const usernameInput = dom('admin-new-username');
const roleSelect = dom('admin-new-role');
if (!usernameInput) return;
const username = usernameInput.value.trim();
if (!username) {
showToast("Nom d'utilisateur requis.");
return;
}
try {
const result = await apiCall('/admin/users', 'POST', {
username: username,
role: roleSelect ? roleSelect.value : 'user'
});
if (result.success && result.generatedPassword) {
usernameInput.value = '';
showPasswordModal(result.generatedPassword);
loadAdminUsers();
}
} catch (err) {
showToast('Erreur: ' + err.message);
}
});
}
// Initial load
loadAdminUsers();
}
};
// --- SOURCE MANAGEMENT ---
function renderSourcesUI() {
const container = dom('sources-sortable-container');
if (!container) return;
container.innerHTML = '';
// Sort available sources so that active ones appear first in their priority order
const allSources = [...state.activeSources];
state.availableSources.forEach(sourceName => {
if (!allSources.includes(sourceName)) {
allSources.push(sourceName);
}
});
const updateWarning = () => {
const warningEl = dom('primary-source-warning');
if (warningEl) {
// Find first active source
const firstActive = state.activeSources[0];
if (firstActive === 'hydracker') {
warningEl.style.display = 'flex';
} else {
warningEl.style.display = 'none';
}
}
};
allSources.forEach(sourceName => {
const item = document.createElement('div');
item.className = 'sortable-source-item';
item.setAttribute('draggable', 'true');
item.dataset.name = sourceName;
const isActive = state.activeSources.includes(sourceName);
const isPrimary = state.activeSources[0] === sourceName;
const displayName = state.sourceLabels[sourceName] || sourceName.toUpperCase();
item.innerHTML = `
<div style="display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1;">
<div class="drag-handle" title="Faites glisser pour réordonner">
<i data-lucide="grip-vertical" style="width: 18px; height: 18px;"></i>
</div>
<div style="width: 10px; height: 10px; border-radius: 50%; background: ${isActive ? 'var(--success)' : '#4b5563'}; flex-shrink: 0;"></div>
<span style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 250px;">${displayName}</span>
${isPrimary ? '<span class="primary-badge" style="font-size: 0.7rem; background: var(--primary); color: white; padding: 2px 6px; border-radius: 4px; font-weight: 700; flex-shrink: 0;">Principale</span>' : ''}
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" value="${sourceName}" ${isActive ? 'checked' : ''} style="width: 20px; height: 20px; cursor: pointer;">
</div>
`;
// Drag and drop event listeners
item.addEventListener('dragstart', (e) => {
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', async () => {
item.classList.remove('dragging');
// Rebuild the priority order from the DOM
const items = Array.from(container.querySelectorAll('.sortable-source-item'));
const newOrderedSources = items.map(el => el.dataset.name);
// Extract active sources in their new order
const newActiveSources = newOrderedSources.filter(name => {
const checkbox = container.querySelector(`[data-name="${name}"] input[type="checkbox"]`);
return checkbox && checkbox.checked;
});
await updateActiveSources(newActiveSources);
});
// Toggle switch logic
const checkbox = item.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', async () => {
// To maintain order, we rebuild the list order from the DOM
const items = Array.from(container.querySelectorAll('.sortable-source-item'));
const newOrderedSources = items.map(el => el.dataset.name);
const newActiveSources = newOrderedSources.filter(name => {
const cb = container.querySelector(`[data-name="${name}"] input[type="checkbox"]`);
return cb && cb.checked;
});
await updateActiveSources(newActiveSources);
});
container.appendChild(item);
});
// Dragover logic on container to allow real-time visual sorting
container.addEventListener('dragover', (e) => {
e.preventDefault();
const draggingEl = container.querySelector('.dragging');
if (!draggingEl) return;
const siblings = Array.from(container.querySelectorAll('.sortable-source-item:not(.dragging)'));
const nextSibling = siblings.find(sibling => {
const rect = sibling.getBoundingClientRect();
const boxCenter = rect.top + rect.height / 2;
return e.clientY < boxCenter;
});
if (nextSibling) {
container.insertBefore(draggingEl, nextSibling);
} else {
container.appendChild(draggingEl);
}
});
updateWarning();
lucide.createIcons(); // Reactivate icons for newly injected HTML
}
// --- UI UPDATES ---
function updateLocalDbFiltersVisibility() {
const hasLocalDb = state.activeSources.includes('localdb');
document.querySelectorAll('.localdb-filter').forEach(el => {
if (hasLocalDb) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
// If a hidden radio is checked, default back to 'film'
const radio = el.querySelector('input[type="radio"]');
if (radio && radio.checked) {
const defaultRadio = document.querySelector('input[name="search-type"][value="film"]');
if (defaultRadio) defaultRadio.checked = true;
}
}
});
}
async function updateActiveSources(sources) {
try {
const res = await apiCall('/set-sources', 'POST', { sources });
state.activeSources = res.activeSources;
renderSourcesUI();
updateLocalDbFiltersVisibility();
showToast('Sources mises à jour');
loadTrending();
} catch(e) {
showToast('Erreur sources: ' + e.message);
renderSourcesUI(); // Revert UI
}
}
// --- DOWNLOADS (LIST) ---
const loadDownloads = async () => {
const list = dom('downloads-list');
if (!list) return;
try {
const data = await apiCall('/download-status');
list.innerHTML = '';
if (!data || !data.length) {
list.innerHTML = `
<div class="empty-state-modern">
<i data-lucide="hard-drive-download"></i>
<p>Aucun téléchargement actif</p>
</div>`;
lucide.createIcons();
return;
}
data.forEach(dl => {
const item = document.createElement('div');
item.className = 'dl-card';
const isDone = dl.percent >= 100;
const barColor = isDone ? 'var(--success)' : 'var(--accent)';
item.innerHTML = `
<div class="dl-icon">
<i data-lucide="${isDone ? 'check-circle' : 'loader-2'}" class="${!isDone ? 'spin-slow' : ''}"></i>
</div>
<div class="dl-content">
<div class="dl-header">
<span class="dl-title">${dl.name}</span>
<span class="dl-percentage">${Math.round(dl.percent)}%</span>
</div>
<div class="dl-bar-bg">
<div class="dl-bar-fill" style="width: ${dl.percent}%; background: ${barColor};"></div>
</div>
<div class="dl-status-text">${isDone ? 'Terminé' : 'Téléchargement en cours...'}</div>
</div>
<button class="btn-jd-delete" title="Supprimer de JDownloader" style="background: none; border: none; color: var(--text-sec); cursor: pointer; padding: 8px; margin-left: 8px; border-radius: 8px; transition: all 0.2s;">
<i data-lucide="trash-2" style="width: 18px; height: 18px;"></i>
</button>
`;
// Attach delete handler
const deleteBtn = item.querySelector('.btn-jd-delete');
deleteBtn.onmouseover = () => { deleteBtn.style.color = '#ef4444'; deleteBtn.style.background = 'rgba(239,68,68,0.1)'; };
deleteBtn.onmouseout = () => { deleteBtn.style.color = 'var(--text-sec)'; deleteBtn.style.background = 'none'; };
deleteBtn.onclick = async (e) => {
e.stopPropagation();
const confirmed = await showConfirmModal(
"Supprimer de JDownloader",
`Êtes-vous sûr de vouloir supprimer "${dl.name}" de JDownloader ?`,
{ confirmText: 'Supprimer', cancelText: 'Annuler', isDestructive: true }
);
if (!confirmed) return;
try {
await apiCall('/jd/remove-link', 'POST', { linkIds: [dl.uuid] });
showToast(`🗑️ ${dl.name} supprimé`);
loadDownloads();
} catch (err) {
showToast('Erreur : ' + err.message);
}
};
list.appendChild(item);
});
lucide.createIcons();
} catch (e) { }
};
const btnRefreshDownloads = dom('btn-refresh-downloads');
if (btnRefreshDownloads) {
btnRefreshDownloads.onclick = loadDownloads;
}
// --- UTILS: BLOCKING LOADER & STATE HELPERS ---
const toggleBlockingLoader = (show, msg = "Traitement en cours...") => {
let loader = document.getElementById('blocking-loader');
if (!loader) {
loader = document.createElement('div');
loader.id = 'blocking-loader';
loader.className = 'hidden';
loader.innerHTML = `<div class="loader"></div><p id="blocking-msg"></p>`;
document.body.appendChild(loader);
}
if (show) {
document.getElementById('blocking-msg').textContent = msg;
loader.classList.remove('hidden');
} else {
loader.classList.add('hidden');
}
};
// --- PROXY IMAGE HELPER ---
// Passe les URLs HTTPS des posters par le proxy serveur pour éviter le blocage mixed-content
const proxyImageUrl = (url) => {
if (!url) return '';
// Si l'image est déjà locale ou en HTTP sur notre domaine, pas besoin de proxy
if (url.startsWith('/') || url.startsWith('data:')) return url;
// Proxy les URLs HTTPS via le serveur
if (url.startsWith('https://')) {
return `/api/proxy-image?url=${encodeURIComponent(url)}`;
}
return url;
};
// --- TRENDING & CARDS ---
const createCard = (movie) => {
const div = document.createElement('div');
div.className = 'card';
const posterSrc = proxyImageUrl(movie.image);
// Build subtitle: quality + lang for ZT, year for Hydracker
let subtitle = movie.year || '';
let cleanedTitle = movie.title;
// Clean ZT titles: "Show Name - Saison X [Quality]" -> "Show Name - Saison X"
if (movie.source === 'zt') {
const parts = [];
// Extract quality info from title if present: "Title [1080p]" -> "Title"
const titleQualityMatch = cleanedTitle.match(/(.*)\s+\[([^\]]+)\]$/);
if (titleQualityMatch) {
cleanedTitle = titleQualityMatch[1].trim();
const qualityFromTitle = titleQualityMatch[2].trim();
if (!movie.quality) movie.quality = qualityFromTitle;
}
if (movie.year) parts.push(movie.year);
if (movie.quality) parts.push(movie.quality);
if (movie.lang) parts.push(movie.lang);
subtitle = parts.join(' — ') || '';
}
const typeBadge = movie.type === 'series' || movie.type === 'anime' ? `<span class="type-badge">${movie.type === 'anime' ? 'Anime' : 'Série'}</span>` : '';
div.innerHTML = `
<div class="poster-container">
<img src="${posterSrc}" loading="lazy" alt="${cleanedTitle}" onerror="this.style.display='none'">
<div class="source-badge" style="position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.1);">
${movie.source}
</div>
</div>
<div class="card-info">
<div class="card-title">${cleanedTitle}</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div class="card-year">${typeBadge} ${subtitle}</div>
<a href="https://www.themoviedb.org/search?query=${encodeURIComponent(cleanedTitle)}" target="_blank" onclick="event.stopPropagation()" class="tmdb-link" title="Rechercher sur TMDB" style="color: var(--primary); opacity: 0.7; transition: opacity 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/></svg>
</a>
</div>
</div>
`;
div.addEventListener('click', () => handleSelection(movie));
// Add hover effect for the TMDB link
const tmdbLink = div.querySelector('.tmdb-link');
if (tmdbLink) {
tmdbLink.addEventListener('mouseenter', () => tmdbLink.style.opacity = '1');
tmdbLink.addEventListener('mouseleave', () => tmdbLink.style.opacity = '0.7');
}
return div;
};
const renderTrending = () => {
const grid = dom('trending-grid');
if (!grid) return;
const type = document.querySelector('input[name="trending-type"]:checked').value;
const itemsToDisplay = type === 'film' ? state.trendingData.films : state.trendingData.series;
grid.innerHTML = '';
if (!itemsToDisplay || !itemsToDisplay.length) {
if (dom('offline-banner') && dom('offline-banner').textContent.includes('Aucune source configurée')) {
grid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 4rem 2rem; color: var(--text-sec);">
<i data-lucide="settings-2" style="width: 48px; height: 48px; margin-bottom: 1.5rem; opacity: 0.5;"></i>
<h3 style="color: white; margin-bottom: 0.5rem;">Aucune source configurée</h3>
<p style="margin-bottom: 2rem; max-width: 400px; margin-left: auto; margin-right: auto;">
Pour afficher du contenu, activez au moins une source dans les paramètres. ZT est recommandé par défaut.
</p>
<button class="btn-primary" onclick="document.querySelector('[data-target=\'section-settings\']').click()" style="padding: 10px 24px;">
Aller aux Paramètres
</button>
</div>`;
lucide.createIcons();
} else {
grid.innerHTML = '<p style="padding:1rem">Aucune tendance trouvée.</p>';
}
return;
}
itemsToDisplay.forEach(m => grid.appendChild(createCard(m)));
};
const renderRecent = () => {
const grid = dom('recent-grid');
if (!grid) return;
// Get filter type from radio buttons
const typeFilter = document.querySelector('input[name="recent-type"]:checked')?.value || 'all';
let itemsToDisplay = state.trendingData.recent || [];
// Apply filter
if (typeFilter === 'film') {
itemsToDisplay = itemsToDisplay.filter(m => m.type === 'movie');
} else if (typeFilter === 'serie') {
itemsToDisplay = itemsToDisplay.filter(m => m.type === 'series' || m.type === 'serie');
}
grid.innerHTML = '';
if (!itemsToDisplay || !itemsToDisplay.length) {
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun ajout récent trouvé pour ce filtre.</p>';
return;
}
itemsToDisplay.forEach(m => grid.appendChild(createCard(m)));
};
const loadTrending = async () => {
const trendingGrid = dom('trending-grid');
const recentGrid = dom('recent-grid');
if (!trendingGrid && !recentGrid) return;
try {
const data = await apiCall('/api/trending');
// Mise à jour de la bannière si le site est down
updateSiteStatusUI(data.isSiteOffline, data.siteOfflineMessage);
state.trendingData = data;
// Si le serveur n'a pas encore fini de scrapper au démarrage
if (data.films.length === 0 && data.series.length === 0 && (!data.recent || data.recent.length === 0)) {
if (trendingGrid) {
trendingGrid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-sec);">
<div class="loader"></div>
<p>Le serveur prépare les tendances, un instant...</p>
</div>`;
}
if (recentGrid) {
recentGrid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-sec);">
<div class="loader"></div>
<p>Le serveur prépare les ajouts récents, un instant...</p>
</div>`;
}
setTimeout(loadTrending, 3000); // On réessaye dans 3 secondes
return;
}
renderTrending();
renderRecent();
} catch (e) {
console.error("Erreur tendances/récents:", e);
if (trendingGrid) {
trendingGrid.innerHTML = '<p style="padding:1rem; color: #e50914;">⚠️ Erreur de liaison avec le serveur.</p>';
}
if (recentGrid) {
recentGrid.innerHTML = '<p style="padding:1rem; color: #e50914;">⚠️ Erreur de liaison avec le serveur.</p>';
}
}
};
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
radio.addEventListener('change', renderTrending);
});
document.querySelectorAll('input[name="recent-type"]').forEach(radio => {
radio.addEventListener('change', renderRecent);
});
// --- SEARCH ---
const searchInput = dom('search-input');
const searchBtn = dom('btn-search-trigger');
const searchRadios = document.querySelectorAll('input[name="search-type"]');
let searchTimeout = null;
const performSearch = async () => {
const q = searchInput ? searchInput.value.trim() : '';
const grid = dom('search-results');
if (!q) {
if (grid) grid.innerHTML = '';
return;
}
// ZT requires min 4 characters
if (state.activeSources.includes('zt') && state.activeSources.length === 1 && q.length < 4) {
if (grid) grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Minimum 4 caractères pour la recherche sur ZT.</p>';
return;
}
const typeEl = document.querySelector('input[name="search-type"]:checked');
const type = typeEl ? typeEl.value : 'film';
if (grid) grid.innerHTML = '<div class="loader-wrapper"><div class="loader"></div></div>';
try {
const res = await apiCall('/search', 'POST', { title: q, mediaType: type });
// Vérification si la recherche n'a pas changé entre temps
if (searchInput.value.trim() !== q) return;
if (grid) {
grid.innerHTML = '';
if (!res || !res.length) {
if (dom('offline-banner') && dom('offline-banner').textContent.includes('Aucune source configurée')) {
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Veuillez configurer une source dans les paramètres.</p>';
} else {
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun résultat.</p>';
}
}
else res.forEach(m => grid.appendChild(createCard(m)));
}
} catch (e) {
if (grid) grid.innerHTML = `<p style="padding:1rem; color:#ef4444;">Erreur: ${e.message}</p>`;
}
};
if (searchInput) {
// Recherche instantanée avec debounce
searchInput.addEventListener('input', () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch();
}, 500); // 500ms d'attente
});
// Entrée pour valider immédiatement
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) clearTimeout(searchTimeout);
performSearch();
}
});
}
if (searchBtn) {
searchBtn.addEventListener('click', (e) => {
e.preventDefault();
if (searchTimeout) clearTimeout(searchTimeout);
performSearch();
});
}
// Relancer la recherche si on change de filtre (Film/Série)
searchRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (searchInput && searchInput.value.trim().length > 0) {
if (searchTimeout) clearTimeout(searchTimeout);
performSearch();
}
});
});
// --- MODAL ---
const handleSelection = async (movie) => {
showModal(movie.title, '<div class="loader-wrapper"><div class="loader"></div></div>');
try {
let ep = '/select-movie';
if (movie.source === 'hydracker' && movie.hrefPath && movie.hrefPath.includes('download')) {
ep = '/select-trending';
}
const data = await apiCall(ep, 'POST', { hrefPath: movie.hrefPath || '', title: movie.title, type: movie.type, source: movie.source });
// Ajout du 3ème argument : movie.source
renderModalOptions(data, movie.title, movie.source);
} catch (e) {
dom('modal-body').innerHTML = `<p style="color:red">${e.message}</p>`;
}
};
function parseSizeToMB(sizeStr) {
if (!sizeStr || sizeStr === 'N/A') return 0;
const match = sizeStr.match(/([\d.,]+)\s*(gb|go|mb|mo|ko|kb|tb|to)/i);
if (!match) return 0;
let size = parseFloat(match[1].replace(',', '.'));
const unit = match[2].toLowerCase();
if (unit.includes('gb') || unit.includes('go')) size *= 1024;
else if (unit.includes('tb')) size *= 1024 * 1024;
else if (unit.includes('kb') || unit.includes('ko')) size /= 1024;
return size;
}
const getQualityRank = (qualityString) => {
const lower = qualityString.toLowerCase();
if (lower.includes("ultra hdlight") && lower.includes("x265")) return 1;
if (lower.includes("1080p") && lower.includes("x265") || lower.includes("1080p light") || lower.includes("x265")) return 2;
return 3;
};
const renderModalOptions = (data, currentTitle = '', source = '') => {
const body = dom('modal-body');
body.innerHTML = '';
const selectedIds = new Set();
const updateBatchUI = () => {
const count = selectedIds.size;
const container = document.getElementById('batch-download-container');
if (container) {
container.style.display = count > 0 ? 'block' : 'none';
const countSpan = document.getElementById('batch-count');
if (countSpan) countSpan.textContent = count;
}
};
// --- 0. TYPE DETECTION ---
const hasSaisonLabel = data.seasons && data.seasons.some(s => s.label.toLowerCase().includes('saison'));
const hasNumericEpisodes = data.clientOptions && data.clientOptions.some(q => {
if (!q.episode) return false;
const lowEp = q.episode.toLowerCase();
if (lowEp.includes('saison complète') || lowEp.includes('intégrale') || lowEp.includes('pack')) return false;
// Si c'est un nombre ou contient "Ep", c'est une série
return /^\d+$/.test(q.episode) || lowEp.includes('ep');
});
const isActuallySeries = hasSaisonLabel || hasNumericEpisodes;
// --- 1. SAISONS / VERSIONS ---
if (data.seasons && data.seasons.length > 0) {
const h4 = document.createElement('h4');
h4.textContent = isActuallySeries ? "Saisons disponibles" : "Qualités & Versions";
h4.className = "modal-subtitle";
body.appendChild(h4);
const seasonsWrapper = document.createElement('div');
seasonsWrapper.className = 'seasons-wrapper';
data.seasons.forEach(s => {
const btn = document.createElement('button');
btn.className = 'version-tab'; // using version-tab for a clean look
btn.innerHTML = `<span>${s.label}</span>`;
btn.title = s.label;
btn.onclick = async () => {
body.innerHTML = '<div class="loader-wrapper"><div class="loader"></div></div>';
try {
const res = await apiCall('/select-season', 'POST', { seasonValue: s.value });
res.seasons = data.seasons;
const baseTitle = currentTitle.split(' - ')[0];
const newTitle = (s.label.toLowerCase().includes('saison') || s.label.toLowerCase().includes('intégrale'))
? `${baseTitle} - ${s.label}`
: currentTitle;
const titleEl = dom('modal-title');
if (titleEl) titleEl.textContent = newTitle;
console.log(`[Modal] Titre mis à jour : ${newTitle}`);
renderModalOptions(res, newTitle, source);
} catch (e) {
body.innerHTML = `<p style="color:red; padding:1rem;">Erreur: ${e.message}</p>`;
}
};
seasonsWrapper.appendChild(btn);
});
body.appendChild(seasonsWrapper);
const sep = document.createElement('hr');
sep.className = 'modal-sep';
body.appendChild(sep);
}
// --- 2. FILES ---
if (!data.clientOptions || !data.clientOptions.length) {
body.innerHTML += '<p class="empty-msg">Aucun fichier disponible.</p>';
return;
}
const MAX_FILM_SIZE_MB = 150000;
const enriched = data.clientOptions.map(q => {
const lowEp = q.episode ? q.episode.toLowerCase() : '';
const isFullSeason = lowEp.includes('saison complète') || lowEp.includes('intégrale') || lowEp.includes('pack') ? 1 : 0;
return {
...q,
sizeVal: parseSizeToMB(q.size),
rank: getQualityRank(q.quality),
isFullSeason
};
})
.filter(q => {
if (!isActuallySeries) return q.sizeVal <= MAX_FILM_SIZE_MB;
return true;
}).sort((a, b) => {
if (a.isFullSeason !== b.isFullSeason) return b.isFullSeason - a.isFullSeason;
if (isActuallySeries) {
const epA = parseInt(String(a.episode || '').replace(/\D/g, '')) || 0;
const epB = parseInt(String(b.episode || '').replace(/\D/g, '')) || 0;
if (epA !== epB) return epA - epB;
}
if (a.rank !== b.rank) return a.rank - b.rank;
return a.sizeVal - b.sizeVal;
});
if (enriched.length === 0) {
body.innerHTML += '<p class="empty-msg">Aucun fichier disponible.</p>';
return;
}
// --- 2a. BUILD VERSION TABS ---
// Gather unique versions from quality field
const allVersions = [...new Set(enriched.map(q => q.quality || 'Inconnu'))];
let activeVersion = allVersions[0];
const h4files = document.createElement('h4');
h4files.textContent = "Fichiers Disponibles";
h4files.className = "modal-subtitle";
body.appendChild(h4files);
// Version filter tabs (only if >1 version)
const versionTabsWrap = document.createElement('div');
versionTabsWrap.className = 'version-tabs';
body.appendChild(versionTabsWrap);
const filesContainer = document.createElement('div');
filesContainer.className = 'files-list';
body.appendChild(filesContainer);
const renderFiles = (version) => {
filesContainer.innerHTML = '';
const toShow = allVersions.length > 1
? enriched.filter(q => (q.quality || 'Inconnu') === version)
: enriched;
if (toShow.length === 0) {
filesContainer.innerHTML = '<p class="empty-msg">Aucun fichier pour cette version.</p>';
return;
}
// Group by host
const hostGroups = new Map();
toShow.forEach(q => {
let hostKey = q.host || 'inconnu';
if (!hostGroups.has(hostKey)) hostGroups.set(hostKey, []);
hostGroups.get(hostKey).push(q);
});
hostGroups.forEach((items, hostName) => {
// Host Header
const hostHeader = document.createElement('div');
hostHeader.className = "season-group-title";
hostHeader.style.marginTop = "1rem";
// Friendly host name
const hostDisplay = hostName
.replace(/\.(png|jpg|webp|gif)$/i, '')
.replace(/[-_]/g, ' ')
.toUpperCase();
hostHeader.textContent = hostDisplay;
filesContainer.appendChild(hostHeader);
// Sort items by episode number
items.sort((a, b) => {
if (a.isFullSeason && !b.isFullSeason) return -1;
if (!a.isFullSeason && b.isFullSeason) return 1;
const numA = a.episode ? parseFloat(a.episode.toString().replace(/[^0-9.]/g, '')) || 0 : 0;
const numB = b.episode ? parseFloat(b.episode.toString().replace(/[^0-9.]/g, '')) || 0 : 0;
return numA - numB;
});
const itemsGrid = document.createElement('div');
itemsGrid.className = isActuallySeries ? "seasons-grid" : "files-list";
filesContainer.appendChild(itemsGrid);
items.forEach((q, idx) => {
const row = document.createElement('div');
let specialClass = '';
let rankIcon = '';
if (q.isFullSeason) { specialClass = 'quality-gold'; rankIcon = '📦'; }
else if (q.rank === 1) { specialClass = 'quality-gold'; rankIcon = '⭐'; }
else if (q.rank === 2) { specialClass = 'quality-blue'; rankIcon = '✨'; }
const episodeLabel = q.episode
? (q.isFullSeason || !/^\d+$/.test(q.episode) ? q.episode : `Ep. ${q.episode}`)
: "Télécharger";
// On vérifie si la source est Hydracker
const isHydracker = source === 'hydracker';
let badgesHtml = '<div style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 6px;">';
if (isActuallySeries && q.size && q.size !== 'N/A') {
badgesHtml += `<span class="badge-size">💾 ${q.size}</span>`;
}
if (q.langs && q.langs.length) {
q.langs.forEach(l => {
if (l && l !== 'unknown') badgesHtml += `<span class="badge-lang">🎧 ${l.replace('Subs:', '💬').replace('Audio:', '🎧')}</span>`;
});
}
if (q.subs && q.subs.length) {
q.subs.forEach(s => {
if (s && s !== 'Inconnu') badgesHtml += `<span class="badge-sub">💬 ${s}</span>`;
});
}
badgesHtml += '</div>';
if (q.releaseName) {
badgesHtml += `<div style="font-size: 0.7rem; color: var(--text-sec); margin-top: 4px; word-break: break-all;">${q.releaseName}</div>`;
}
if (isActuallySeries) {
row.className = `quality-pill ${specialClass}`;
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.padding = "8px 14px";
row.style.cursor = "pointer";
row.style.gap = "20px";
let buttonsHtml = '';
if (isHydracker) {
buttonsHtml = `
<div style="display: flex; gap: 6px; align-items: center;">
<button class="btn-action btn-movix" title="Lien Direct (Movix)" style="background: rgba(139, 92, 246, 0.2); border: 1px solid rgba(139, 92, 246, 0.5); color: #8b5cf6; border-radius: 6px; padding: 4px 8px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
<i data-lucide="zap" style="width: 16px; height: 16px;"></i>
</button>
<button class="btn-action btn-dl" title="Télécharger classique" style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1); color: white; border-radius: 6px; padding: 4px 8px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
</button>
</div>
`;
} else {
buttonsHtml = `
<button class="btn-action btn-dl" title="Télécharger" style="background: transparent; border: none; color: white; cursor: pointer; display: flex; align-items: center;">
<i data-lucide="download" style="width: 18px; height: 18px;"></i>
</button>
`;
}
const isChecked = selectedIds.has(q.id);
row.innerHTML = `
<div style="display: flex; flex-direction: column; flex: 1;">
<div style="display: flex; align-items: center;">
<div class="batch-checkbox" data-id="${q.id}" style="cursor: pointer; display: flex; align-items: center; color: ${isChecked ? 'var(--primary)' : 'var(--text-sec)'}; margin-right: 8px;">
<i data-lucide="${isChecked ? 'check-square' : 'square'}" style="width: 18px; height: 18px;"></i>
</div>
${rankIcon ? `<span style="margin-right:5px">${rankIcon}</span>` : ''}
<span style="font-weight: 600;">${episodeLabel}</span>
</div>
${badgesHtml}
</div>
${buttonsHtml}
`;
} else {
row.className = `file-btn ${specialClass}`;
row.style.cursor = "pointer";
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
const mirrorLabel = items.length > 1 ? ` <span class="mirror-tag">Miroir ${idx + 1}</span>` : '';
let buttonsHtml = '';
if (isHydracker) {
buttonsHtml = `
<div class="file-btn-right" style="display: flex; gap: 8px; align-items: center;">
<button class="btn-action btn-movix" title="Lien Direct (Movix)" style="background: rgba(139, 92, 246, 0.15); border: 1px solid #8b5cf6; color: #8b5cf6; padding: 6px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-weight: 600; transition: all 0.2s;">
<i data-lucide="zap" style="width: 16px; height: 16px;"></i> Movix
</button>
<button class="btn-action btn-dl" title="Télécharger classique" style="background: rgba(255, 255, 255, 0.1); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
</button>
</div>
`;
} else {
buttonsHtml = `
<div class="file-btn-right">
<button class="btn-action btn-dl" title="Télécharger" style="background: transparent; border: none; color: white; cursor: pointer;">
<i data-lucide="download" class="dl-icon-btn"></i>
</button>
</div>
`;
}
const isChecked = selectedIds.has(q.id);
row.innerHTML = `
<div class="file-btn-left" style="display: flex; flex-direction: column; width: 100%; padding-right: 15px;">
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">
<div class="file-host" style="display: flex; align-items: center;">
<div class="batch-checkbox" data-id="${q.id}" style="cursor: pointer; display: flex; align-items: center; color: ${isChecked ? 'var(--primary)' : 'var(--text-sec)'}; margin-right: 8px;">
<i data-lucide="${isChecked ? 'check-square' : 'square'}" style="width: 18px; height: 18px;"></i>
</div>
${rankIcon ? `<span class="rank-icon">${rankIcon}</span>` : ''}
<span class="host-name">${hostDisplay}</span>
${mirrorLabel}
${q.episode ? `<span class="episode-tag">${q.episode}</span>` : ''}
</div>
<div class="file-size" style="margin-left: 15px; flex-shrink: 0;">${q.size && q.size !== 'N/A' ? q.size : 'Taille inconnue'}</div>
</div>
${badgesHtml}
</div>
${buttonsHtml}
`;
}
const chk = row.querySelector('.batch-checkbox');
if (chk) {
chk.onclick = (e) => {
e.stopPropagation();
if (selectedIds.has(q.id)) {
selectedIds.delete(q.id);
chk.innerHTML = '<i data-lucide="square" style="width: 18px; height: 18px;"></i>';
chk.style.color = 'var(--text-sec)';
} else {
selectedIds.add(q.id);
chk.innerHTML = '<i data-lucide="check-square" style="width: 18px; height: 18px;"></i>';
chk.style.color = 'var(--primary)';
}
lucide.createIcons({ root: chk });
updateBatchUI();
};
}
// On attache l'event listener Movix UNIQUEMENT si on est sur Hydracker
if (isHydracker) {
row.querySelector('.btn-movix').onclick = async (e) => {
e.stopPropagation();
toggleBlockingLoader(true, "Débridage Movix en cours...");
try {
const result = await apiCall(`/movix-decode/${q.id}`, 'GET');
toggleBlockingLoader(false);
if (result.link) {
let toggleEl = document.getElementById('toggle-jd');
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
if (useJD) {
showToast('Lien Movix envoyé à JDownloader !');
await apiCall('/jd/add', 'POST', { link: result.link, packageName: q.releaseName || 'Movix Download', isSeries: isActuallySeries });
} else {
showDirectLinkModal('Lien Direct Movix', `
<div class="direct-link-box">
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">Voici votre lien direct rapide :</p>
<div style="position: relative; width: 100%; margin-bottom: 1.25rem;">
<input type="text" value="${result.link}" readonly id="direct-link-input" class="select-on-click"
style="width: 100%; background: #0a0a0a; border: 1px solid var(--border); border-radius: 10px; color: #ffffff; padding: 14px 44px 14px 16px; font-family: monospace; font-size: 0.95rem; text-align: left; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">
<button class="copy-btn-target" data-copy-target="direct-link-input" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-sec); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px;"
title="Copier le lien">
<i data-lucide="copy" style="width: 18px; height: 18px;"></i>
</button>
</div>
<div class="btn-group" style="display: flex; gap: 10px;">
<button class="btn-action copy-btn-target" data-copy-target="direct-link-input" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copier le lien
</button>
<a href="${result.link}" target="_blank" class="btn-success" style="flex: 1; text-align: center; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i> Ouvrir le lien
</a>
</div>
</div>
`);
}
}
} catch (err) {
toggleBlockingLoader(false);
showToast("Erreur Movix: " + err.message);
show(dom('modal-overlay'));
}
};
}
// L'event listener classique fonctionne pour toutes les sources
row.onclick = async (e) => {
e.stopPropagation();
let toggleEl = document.getElementById('toggle-jd');
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
let forcedDirect = (source === 'ztnews');
if (forcedDirect) {
useJD = false;
}
toggleBlockingLoader(true, "Récupération du lien...");
try {
let result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD });
if (result.status === 'challenge' && result.challenge) {
toggleBlockingLoader(false);
const captchaData = await window.handleCaptchaChallenge(result.challenge);
toggleBlockingLoader(true, "Vérification du captcha...");
result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD, captchaData });
}
toggleBlockingLoader(false);
if (useJD) {
showToast('Lien envoyé à JDownloader !');
} else {
if (forcedDirect) {
showToast("⚠️ Cloudflare Turnstile actif. Résolution manuelle requise.");
}
const titleText = forcedDirect ? 'Lien Direct (Protection Cloudflare)' : 'Lien Direct';
const descText = forcedDirect
? 'Ce lien est protégé par Cloudflare. Cliquez sur "Ouvrir" pour le déverrouiller manuellement :'
: 'Voici votre lien direct rapide :';
showDirectLinkModal(titleText, `
<div class="direct-link-box">
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">${descText}</p>
<div style="position: relative; width: 100%; margin-bottom: 1.25rem;">
<input type="text" value="${result.link}" readonly id="direct-link-input-2" class="select-on-click"
style="width: 100%; background: #0a0a0a; border: 1px solid var(--border); border-radius: 10px; color: #ffffff; padding: 14px 44px 14px 16px; font-family: monospace; font-size: 0.95rem; text-align: left; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">
</div>
<div class="btn-group" style="display: flex; gap: 10px;">
<button class="btn-action copy-btn-target" data-copy-target="direct-link-input-2" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copier le lien
</button>
<a href="${result.link}" target="_blank" class="btn-success" style="flex: 1; text-align: center; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i> Ouvrir le lien
</a>
</div>
</div>
`);
}
} catch (err) {
toggleBlockingLoader(false);
showToast("Erreur: " + err.message);
show(dom('modal-overlay'));
}
};
row.querySelectorAll('.btn-action').forEach(b => {
b.addEventListener('mouseover', () => b.style.transform = 'scale(1.05)');
b.addEventListener('mouseout', () => b.style.transform = 'scale(1)');
});
itemsGrid.appendChild(row);
});
});
lucide.createIcons();
};
if (allVersions.length > 1) {
allVersions.forEach(version => {
const tab = document.createElement('button');
tab.className = 'version-tab';
tab.textContent = version;
if (version === activeVersion) tab.classList.add('active');
tab.onclick = () => {
activeVersion = version;
versionTabsWrap.querySelectorAll('.version-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderFiles(version);
};
versionTabsWrap.appendChild(tab);
});
}
renderFiles(activeVersion);
const batchDownloadContainer = document.createElement('div');
batchDownloadContainer.id = 'batch-download-container';
batchDownloadContainer.style = 'display: none; padding-top: 1rem; border-top: 1px solid var(--border); margin-top: 1rem;';
batchDownloadContainer.innerHTML = `
<button class="btn-primary" id="btn-batch-download" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px;">
<i data-lucide="download"></i> Télécharger la sélection (<span id="batch-count">0</span>)
</button>
`;
body.appendChild(batchDownloadContainer);
const btnBatch = batchDownloadContainer.querySelector('#btn-batch-download');
btnBatch.onclick = async () => {
if (selectedIds.size === 0) return;
let toggleEl = document.getElementById('toggle-jd');
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
let forcedDirect = (source === 'ztnews');
if (forcedDirect) {
useJD = false;
}
try {
toggleBlockingLoader(true, "Récupération des liens...");
let result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD });
if (result.status === 'challenge' && result.challenge) {
toggleBlockingLoader(false);
const captchaData = await window.handleCaptchaChallenge(result.challenge);
toggleBlockingLoader(true, "Vérification du captcha et récupération...");
result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD, captchaData });
}
toggleBlockingLoader(false);
if (result.errors) {
showToast('Erreurs: ' + result.errors.join(', '));
}
if (useJD) {
showToast(result.message || 'Liens envoyés à JDownloader !');
} else {
if (forcedDirect) {
showToast("⚠️ Cloudflare Turnstile actif. Résolution manuelle requise.");
}
const titleText = forcedDirect ? 'Liens Directs (Protection Cloudflare)' : 'Liens Directs';
const descText = forcedDirect
? 'Ces liens sont protégés par Cloudflare. Copiez-les pour les ouvrir manuellement :'
: 'Voici vos liens directs :';
showDirectLinkModal(titleText, `
<div class="direct-link-box">
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">${descText}</p>
<textarea readonly id="direct-links-input" class="select-on-click"
style="width: 100%; height: 150px; background: #0a0a0a; border: 1px solid var(--border); color: #ffffff; border-radius: 10px; margin-bottom: 1.25rem; padding: 14px; font-family: monospace; font-size: 0.95rem; white-space: pre; resize: none; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">${(result.links || []).join('\n')}</textarea>
<div class="btn-group" style="display: flex; gap: 10px;">
<button class="btn-action copy-btn-target" data-copy-target="direct-links-input" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Tout copier
</button>
</div>
</div>
`);
}
} catch (err) {
toggleBlockingLoader(false);
showToast("Erreur: " + err.message);
show(dom('modal-overlay'));
}
};
updateBatchUI();
lucide.createIcons({ root: batchDownloadContainer });
};
const showModal = (title, content) => {
dom('modal-title').textContent = title;
dom('modal-body').innerHTML = content;
show(dom('modal-overlay'));
};
window.showModal = showModal;
const showDirectLinkModal = (title, content) => {
const existing = document.getElementById('direct-link-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'direct-link-modal-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '10001';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>${title}</h3>
<button id="direct-modal-close"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">${content}</div>
</div>
`;
document.body.appendChild(overlay);
lucide.createIcons({ root: overlay });
show(overlay);
// CSP Safe Event Listeners
const selects = overlay.querySelectorAll('.select-on-click');
selects.forEach(el => el.addEventListener('click', function() { this.select(); }));
const copyBtns = overlay.querySelectorAll('.copy-btn-target');
copyBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.getAttribute('data-copy-target');
const targetEl = document.getElementById(targetId);
if (targetEl) {
navigator.clipboard.writeText(targetEl.value)
.then(() => showToast('Copié !'))
.catch(() => showToast('Erreur de copie !'));
}
});
});
const closeBtn = overlay.querySelector('#direct-modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
hide(overlay);
setTimeout(() => overlay.remove(), 300);
});
}
};
window.handleCaptchaChallenge = function(challenge) {
return new Promise((resolve, reject) => {
const existing = document.getElementById('captcha-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'captcha-modal-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '10005';
if (challenge.captcha === 'turnstile') {
overlay.innerHTML = `
<div class="modal-content" style="max-width: 450px; text-align: center;">
<div class="modal-header">
<h3>Protection Cloudflare</h3>
<button id="captcha-modal-close"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<p style="margin-bottom: 15px; color: var(--text-sec);">Ce lien est protégé par Cloudflare. Vous devez le résoudre manuellement sur le site source.</p>
<p style="margin-bottom: 20px; color: var(--text-sec); font-size: 0.9em;">Une fois sur ${challenge.sourceName || 'le site'}, cliquez sur le lien, résolvez le captcha, puis copiez le lien 1Fichier obtenu pour l'ajouter dans l'onglet "Ajout Manuel" d'Agora.</p>
<a href="${challenge.url}" target="_blank" class="btn-action" style="display: inline-flex; align-items: center; gap: 8px; background: var(--primary); color: var(--bg); padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 600;">
<i data-lucide="external-link" style="width: 18px; height: 18px;"></i> Ouvrir ${challenge.sourceName || 'le site'}
</a>
</div>
</div>
`;
} else {
overlay.innerHTML = `
<div class="modal-content" style="max-width: 400px; text-align: center;">
<div class="modal-header">
<h3>Vérification requise</h3>
<button id="captcha-modal-close"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<p style="margin-bottom: 15px; color: var(--text-sec);">Captcha non supporté (${challenge.captcha}).</p>
</div>
</div>
`;
}
document.body.appendChild(overlay);
lucide.createIcons({ root: overlay });
show(overlay);
const closeBtn = overlay.querySelector('#captcha-modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
reject(new Error(challenge.captcha === 'turnstile' ? 'Redirection manuelle requise par Cloudflare.' : 'Captcha annulé.'));
hide(overlay);
setTimeout(() => overlay.remove(), 300);
});
}
});
};
const modalClose = dom('modal-close');
if (modalClose) {
modalClose.onclick = () => {
hide(dom('modal-overlay'));
};
}
const btnLogout = dom('btn-logout');
if (btnLogout) {
btnLogout.onclick = async () => {
await apiCall('/logout', 'POST');
window.location.href = '/login';
};
}
// --- MANUAL ADD TO JD ---
const btnManualSubmit = dom('btn-manual-submit');
if (btnManualSubmit) {
const typeOptions = document.querySelectorAll('.manual-type-selector .type-option');
const manualTypeInput = dom('manual-type');
typeOptions.forEach(opt => {
opt.onclick = () => {
typeOptions.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
if (manualTypeInput) {
manualTypeInput.value = opt.dataset.type;
}
};
});
btnManualSubmit.onclick = async () => {
const title = dom('manual-title').value.trim();
const linksText = dom('manual-links').value.trim();
const typeValue = manualTypeInput ? manualTypeInput.value : 'film';
const isSeries = typeValue === 'series';
if (!title) {
showToast("⚠️ Veuillez entrer un titre.");
return;
}
if (!linksText) {
showToast("⚠️ Veuillez ajouter au moins un lien.");
return;
}
toggleBlockingLoader(true, "Envoi des liens à JDownloader...");
try {
const res = await apiCall('/jd/add', 'POST', {
links: linksText,
packageName: title,
isSeries: isSeries
});
toggleBlockingLoader(false);
if (res.success) {
showToast(`${res.count} lien(s) envoyé(s) à JDownloader !`);
dom('manual-title').value = '';
dom('manual-links').value = '';
// Reset selector style
typeOptions.forEach(o => o.classList.remove('active'));
if (typeOptions[0]) typeOptions[0].classList.add('active');
if (manualTypeInput) manualTypeInput.value = 'film';
setTimeout(() => { window.location.href = '/downloads'; }, 1000);
} else {
showToast("⚠️ Échec de l'envoi.");
}
} catch (err) {
toggleBlockingLoader(false);
showToast("Erreur: " + err.message);
}
};
}
// Start
checkSession();
// --- DOWNLOAD AUTOMATION ---
if (dom('downloads-list')) {
startDownloadLoop();
}
});