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 = `
${title}
${message}
${confirmText}
${cancelText}
`;
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 = `
Sécurité requise
Votre compte utilise un mot de passe temporaire ou réinitialisé. Vous devez définir un nouveau mot de passe sécurisé pour continuer.
Exigences du mot de passe :
Au moins 8 caractères
Au moins une majuscule (A-Z)
Au moins un chiffre (0-9)
Au moins un caractère spécial
Mots de passe identiques
`;
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);
});
// --- 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 = `
${user.username}${isCurrentUser ? ' (vous) ' : ''}
${roleLabel}
${!isCurrentUser ? `
` : ''}
`;
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 = `
${displayName}
${isPrimary ? '
Principale ' : ''}
`;
// 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 = `
Aucun téléchargement actif
`;
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 = `
${isDone ? 'Terminé' : 'Téléchargement en cours...'}
`;
// 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 = `
`;
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.quality) parts.push(movie.quality);
if (movie.lang) parts.push(movie.lang);
subtitle = parts.join(' — ') || '';
}
const typeBadge = movie.type === 'series' || movie.type === 'anime' ? `${movie.type === 'anime' ? 'Anime' : 'Série'} ` : '';
div.innerHTML = `
${movie.source}
${cleanedTitle}
${typeBadge} ${subtitle}
`;
div.addEventListener('click', () => handleSelection(movie));
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 = `
Aucune source configurée
Pour afficher du contenu, activez au moins une source dans les paramètres. ZT est recommandé par défaut.
Aller aux Paramètres
`;
lucide.createIcons();
} else {
grid.innerHTML = 'Aucune tendance trouvée.
';
}
return;
}
itemsToDisplay.forEach(m => grid.appendChild(createCard(m)));
};
const renderRecent = () => {
const grid = dom('recent-grid');
if (!grid) return;
const itemsToDisplay = state.trendingData.recent || [];
grid.innerHTML = '';
if (!itemsToDisplay || !itemsToDisplay.length) {
grid.innerHTML = 'Aucun ajout récent trouvé.
';
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 = `
Le serveur prépare les tendances, un instant...
`;
}
if (recentGrid) {
recentGrid.innerHTML = `
Le serveur prépare les ajouts récents, un instant...
`;
}
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 = '⚠️ Erreur de liaison avec le serveur.
';
}
if (recentGrid) {
recentGrid.innerHTML = '⚠️ Erreur de liaison avec le serveur.
';
}
}
};
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
radio.addEventListener('change', renderTrending);
});
// --- 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 = 'Minimum 4 caractères pour la recherche sur ZT.
';
return;
}
const typeEl = document.querySelector('input[name="search-type"]:checked');
const type = typeEl ? typeEl.value : 'film';
if (grid) grid.innerHTML = '';
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 = 'Veuillez configurer une source dans les paramètres.
';
} else {
grid.innerHTML = 'Aucun résultat.
';
}
}
else res.forEach(m => grid.appendChild(createCard(m)));
}
} catch (e) {
if (grid) grid.innerHTML = `Erreur: ${e.message}
`;
}
};
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, '');
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 = `${e.message}
`;
}
};
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 = `${s.label} `;
btn.title = s.label;
btn.onclick = async () => {
body.innerHTML = '';
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 = `Erreur: ${e.message}
`;
}
};
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 += 'Aucun fichier disponible.
';
return;
}
const MAX_FILM_SIZE_MB = 45360;
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 += 'Aucun fichier disponible.
';
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 = 'Aucun fichier pour cette version.
';
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 = '';
if (isActuallySeries && q.size && q.size !== 'N/A') {
badgesHtml += `💾 ${q.size} `;
}
if (q.langs && q.langs.length) {
q.langs.forEach(l => {
if (l && l !== 'unknown') badgesHtml += `🎧 ${l.replace('Subs:', '💬').replace('Audio:', '🎧')} `;
});
}
if (q.subs && q.subs.length) {
q.subs.forEach(s => {
if (s && s !== 'Inconnu') badgesHtml += `💬 ${s} `;
});
}
badgesHtml += '
';
if (q.releaseName) {
badgesHtml += `${q.releaseName}
`;
}
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 = `
`;
} else {
buttonsHtml = `
`;
}
const isChecked = selectedIds.has(q.id);
row.innerHTML = `
${rankIcon ? `
${rankIcon} ` : ''}
${episodeLabel}
${badgesHtml}
${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 ? ` Miroir ${idx + 1} ` : '';
let buttonsHtml = '';
if (isHydracker) {
buttonsHtml = `
Movix
`;
} else {
buttonsHtml = `
`;
}
const isChecked = selectedIds.has(q.id);
row.innerHTML = `
${rankIcon ? `
${rankIcon} ` : ''}
${hostDisplay}
${mirrorLabel}
${q.episode ? `
${q.episode} ` : ''}
${q.size && q.size !== 'N/A' ? q.size : 'Taille inconnue'}
${badgesHtml}
${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 = ' ';
chk.style.color = 'var(--text-sec)';
} else {
selectedIds.add(q.id);
chk.innerHTML = ' ';
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', `
`);
}
}
} 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 {
const result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD });
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, `
`);
}
} 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 = `
Télécharger la sélection (0 )
`;
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;
}
toggleBlockingLoader(true, "Récupération des liens...");
try {
const result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD });
toggleBlockingLoader(false);
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, `
`);
}
} 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'));
};
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 = `
`;
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);
});
}
};
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();
}
});