Files
ohm_streaming/templates/index.html
T
root 81f1b7708c fix: Improve grouping functionality and add visual indicators
- Add collapsible arrow indicator for groups (▼)
- Improve extractSeriesName() to handle edge cases better
- Fix displayDownloads() to properly handle grouping
- Add proper sorting for group names
- Groups are now properly displayed with visual toggle state
- Better handling of filenames with special characters
- Remove trailing dashes/underscores from series names
2026-01-23 10:46:38 +00:00

1715 lines
64 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ohm Stream Downloader</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #eee;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
font-size: 2.5em;
background: linear-gradient(45deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 30px;
font-size: 0.9em;
}
.url-form {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 25px;
margin-bottom: 30px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
input[type="text"] {
flex: 1;
padding: 12px 15px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 14px;
transition: all 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
button {
padding: 12px 25px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
gap: 6px;
}
button svg {
width: 16px;
height: 16px;
}
.btn-small svg {
width: 14px;
height: 14px;
}
.btn-primary {
background: linear-gradient(45deg, #00d9ff, #00ff88);
color: #000;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 217, 255, 0.4);
}
.btn-small {
padding: 6px 12px;
font-size: 11px;
}
.btn-pause {
background: #ffa500;
color: #000;
}
.btn-resume {
background: #00ff88;
color: #000;
}
.btn-cancel {
background: #ff4444;
color: #fff;
}
.btn-download {
background: #00d9ff;
color: #000;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
margin-top: 40px;
}
.section-header h2 {
font-size: 1.8em;
margin: 0;
background: linear-gradient(45deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.downloads-stats {
display: flex;
gap: 15px;
font-size: 0.85em;
}
.stat-item {
background: rgba(255, 255, 255, 0.05);
padding: 5px 12px;
border-radius: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-count {
font-weight: bold;
color: #00d9ff;
}
.downloads-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.03);
padding: 15px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 0.85em;
color: #aaa;
white-space: nowrap;
}
.filter-group select,
.filter-group input {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 13px;
min-width: 120px;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #00d9ff;
}
.search-group input {
min-width: 200px;
}
.actions-group {
margin-left: auto;
}
.downloads-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.downloads-group {
margin-bottom: 20px;
}
.downloads-group-header {
background: rgba(255, 255, 255, 0.08);
padding: 12px 18px;
border-radius: 8px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
position: relative;
}
.downloads-group-header:hover {
background: rgba(255, 255, 255, 0.12);
}
.downloads-group-header::before {
content: '▼';
position: absolute;
right: 18px;
font-size: 0.8em;
transition: transform 0.3s;
}
.downloads-group-header.collapsed::before {
transform: rotate(-90deg);
}
.downloads-group-title {
font-weight: 600;
font-size: 1.05em;
color: #00d9ff;
padding-right: 30px;
}
.downloads-group-count {
background: rgba(0, 217, 255, 0.2);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85em;
color: #00d9ff;
}
.downloads-group-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.download-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.download-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.filename {
font-weight: 600;
color: #00d9ff;
font-size: 16px;
}
.status {
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-pending { background: #666; }
.status-downloading { background: #00d9ff; color: #000; }
.status-paused { background: #ffa500; color: #000; }
.status-completed { background: #00ff88; color: #000; }
.status-failed { background: #ff4444; }
.status-cancelled { background: #999; }
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #00d9ff, #00ff88);
transition: width 0.3s;
border-radius: 4px;
}
.download-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #888;
margin-bottom: 10px;
}
.download-actions {
display: flex;
gap: 8px;
}
.url-display {
font-size: 11px;
color: #666;
word-break: break-all;
margin-top: 8px;
}
.error-message {
color: #ff4444;
font-size: 12px;
margin-top: 8px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
.supported-hosts {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
justify-content: center;
}
.host-badge {
background: rgba(255, 255, 255, 0.1);
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
color: #888;
}
/* Tabs */
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.tab {
padding: 10px 20px;
background: transparent;
color: #888;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
text-transform: none;
letter-spacing: 0;
}
.tab:hover {
color: #00d9ff;
}
.tab.active {
color: #00d9ff;
border-bottom-color: #00d9ff;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.anime-input-group {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.anime-select-group {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
select {
padding: 12px 15px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 14px;
transition: all 0.3s;
cursor: pointer;
}
select:focus {
outline: none;
border-color: #00d9ff;
}
.search-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 15px;
margin-top: 20px;
}
.anime-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.anime-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(0, 217, 255, 0.3);
transform: translateY(-2px);
}
.anime-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.anime-card-title {
font-size: 16px;
font-weight: 600;
color: #fff;
}
.anime-card-provider {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 217, 255, 0.2);
color: #00d9ff;
}
.anime-card-actions {
display: flex;
gap: 8px;
margin-top: 15px;
}
.anime-card-actions select {
flex: 1;
padding: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 13px;
}
.anime-card-actions button {
flex: 1;
padding: 8px 12px;
font-size: 12px;
}
.anime-metadata {
font-size: 12px;
color: #aaa;
margin-bottom: 10px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
line-height: 1.6;
}
.anime-synopsis {
margin-bottom: 10px;
padding: 10px 12px;
background: rgba(0, 217, 255, 0.05);
border-left: 3px solid #00d9ff;
border-radius: 6px;
}
.anime-synopsis summary {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #00d9ff;
margin-bottom: 8px;
user-select: none;
}
.anime-synopsis summary:hover {
color: #00ff88;
}
.anime-synopsis p {
font-size: 12px;
color: #ccc;
line-height: 1.5;
margin: 0;
max-height: 200px;
overflow-y: auto;
}
.loading-spinner {
text-align: center;
padding: 40px;
color: #888;
}
.loading-spinner::after {
content: "";
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid rgba(0, 217, 255, 0.3);
border-top-color: #00d9ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.no-results {
text-align: center;
padding: 40px;
color: #888;
}
/* Mobile Responsive */
@media (max-width: 768px) {
body {
padding: 10px;
}
h1 {
font-size: 1.8em;
}
.container {
max-width: 100%;
}
.url-form {
padding: 15px;
}
.input-group {
flex-direction: column;
gap: 10px;
}
.btn-primary {
width: 100%;
justify-content: center;
}
.download-item {
padding: 15px;
}
.filename {
font-size: 14px;
word-break: break-word;
}
.download-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.status {
align-self: flex-start;
}
.download-actions {
flex-wrap: wrap;
gap: 6px;
}
.btn-small {
padding: 8px 10px;
font-size: 10px;
flex: 1 1 auto;
min-width: 80px;
justify-content: center;
}
.download-info {
flex-direction: column;
gap: 4px;
}
.supported-hosts {
gap: 6px;
}
}
@media (max-width: 480px) {
h1 {
font-size: 1.5em;
}
.btn-small {
min-width: 70px;
padding: 6px 8px;
font-size: 9px;
}
.btn-small svg {
width: 12px;
height: 12px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>⚡ Ohm Stream Downloader</h1>
<p class="subtitle">Téléchargez vos vidéos et animes depuis vos hébergeurs préférés</p>
<!-- Tabs (Anime providers will be added dynamically) -->
<div class="tabs">
<button class="tab active" data-tab-type="search" onclick="switchTab('search')">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Recherche
</button>
<button class="tab" data-tab-type="direct" onclick="switchTab('direct')">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Lien direct
</button>
<!-- Anime provider tabs will be loaded dynamically -->
</div>
<!-- Search Tab -->
<div id="tab-search" class="tab-content active">
<div class="url-form">
<div class="input-group">
<input
type="text"
id="searchInput"
placeholder="Rechercher un anime..."
onkeypress="if(event.key === 'Enter') searchAnime()"
>
<select id="langSelect" style="max-width: 120px;">
<option value="vostfr">VOSTFR</option>
<option value="vf">VF</option>
</select>
<button type="button" class="btn-primary" onclick="searchAnime()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Rechercher
</button>
</div>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 13px; color: #888;">
<input type="checkbox" id="includeMetadata" style="width: auto; margin: 0;">
<label for="includeMetadata" style="cursor: pointer; user-select: none;">
📊 Inclure les métadonnées (synopsis, genres, note) • Plus lent mais plus complet
</label>
</div>
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #88;">
💡 <strong>Astuce:</strong> Pour de meilleurs résultats, essayez le nom en anglais ou japonais (ex: "One Piece", "Naruto"). Certains sites n'ont pas tous les animes.
</div>
</div>
<div id="searchResults" class="search-results"></div>
</div>
<!-- Direct Download Tab -->
<div id="tab-direct" class="tab-content">
<div class="url-form">
<form id="downloadForm">
<div class="input-group">
<input
type="text"
id="urlInput"
placeholder="Collez le lien de téléchargement ici..."
required
>
<button type="submit" class="btn-primary">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
</div>
</form>
<div class="supported-hosts">
<span class="host-badge">1fichier</span>
<span class="host-badge">Doodstream</span>
<span class="host-badge">Rapidfile</span>
<span class="host-badge">Anime-Sama</span>
<span class="host-badge">Anime-Ultime</span>
</div>
</div>
</div>
<!-- Downloads Section with Filters -->
<div class="section-header">
<h2>Téléchargements</h2>
<div class="downloads-stats" id="downloadsStats"></div>
</div>
<!-- Filters and Controls -->
<div class="downloads-controls">
<div class="filter-group">
<label>Statut:</label>
<select id="statusFilter" onchange="filterDownloads()">
<option value="all">Tous</option>
<option value="downloading">En cours</option>
<option value="paused">En pause</option>
<option value="completed">Terminés</option>
<option value="cancelled">Annulés</option>
<option value="failed">Échoués</option>
</select>
</div>
<div class="filter-group">
<label>Tri par:</label>
<select id="sortBy" onchange="filterDownloads()">
<option value="date">Date (récent)</option>
<option value="date_asc">Date (ancien)</option>
<option value="name">Nom (A-Z)</option>
<option value="name_desc">Nom (Z-A)</option>
<option value="size">Taille</option>
</select>
</div>
<div class="filter-group">
<label>Regroupement:</label>
<select id="groupBy" onchange="filterDownloads()">
<option value="none">Aucun</option>
<option value="series">Par série</option>
<option value="status">Par statut</option>
<option value="day">Par jour</option>
</select>
</div>
<div class="filter-group search-group">
<input type="text" id="searchDownloads" placeholder="🔍 Rechercher..." oninput="filterDownloads()">
</div>
<div class="actions-group">
<button class="btn-small btn-secondary" onclick="clearCompleted()" title="Supprimer les terminés">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Nettoyer
</button>
</div>
</div>
<div id="downloadsList" class="downloads-list">
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<p>Aucun téléchargement pour le moment</p>
</div>
</div>
</div>
<script>
const API_BASE = '/api';
let autoRefreshInterval;
let currentAnimeUrl = '';
let searchResultsCache = {};
let allDownloads = []; // Store all downloads for filtering
// Extract series name from filename (for grouping)
function extractSeriesName(filename) {
// Common patterns:
// - "Naruto Shippuden - Episode 123.mp4"
// - "[One Piece] Ep 456.mkv"
// - "Attack on Titan S03E09.mp4"
// - "Anime Name - S01E05.mp4"
let name = filename;
// Remove file extension
name = name.replace(/\.[^/.]+$/, '');
// Remove episode numbers and patterns
name = name
.replace(/[-_ ]?(E(?:p)?|Episode|Épisode|Saison|Season)[-_: ]?\d+/gi, '')
.replace(/[-_ ]?S\d{2}E\d{2}/gi, '')
.replace(/\[.*?\]/g, '')
.replace(/\(.*\)/g, '')
.replace(/[-_ ]?\d{3,4}p/gi, '')
.replace(/[-_ ]?(VOSTFR|VF|MULTI)/gi, '')
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.replace(/[-_]+$/, '') // Remove trailing dashes/underscores
.trim();
// If nothing left or too short, use original filename without extension
if (!name || name.length < 3) {
return filename.replace(/\.[^/.]+$/, '');
}
return name;
}
// Get day string for grouping
function getDayString(dateString) {
const date = new Date(dateString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return "Aujourd'hui";
} else if (date.toDateString() === yesterday.toDateString()) {
return "Hier";
} else {
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' });
}
}
// Filter and sort downloads
function filterDownloads() {
const statusFilter = document.getElementById('statusFilter').value;
const sortBy = document.getElementById('sortBy').value;
const groupBy = document.getElementById('groupBy').value;
const searchTerm = document.getElementById('searchDownloads').value.toLowerCase();
// Filter by status and search
let filtered = allDownloads.filter(dl => {
const matchesStatus = statusFilter === 'all' || dl.status === statusFilter;
const matchesSearch = !searchTerm ||
dl.filename.toLowerCase().includes(searchTerm) ||
(dl.url && dl.url.toLowerCase().includes(searchTerm));
return matchesStatus && matchesSearch;
});
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'name':
return a.filename.localeCompare(b.filename);
case 'name_desc':
return b.filename.localeCompare(a.filename);
case 'size':
return (b.total_bytes || 0) - (a.total_bytes || 0);
case 'date':
default:
return new Date(b.created_at) - new Date(a.created_at);
}
});
// Apply grouping
displayDownloads(filtered, groupBy);
}
// Group downloads
function groupDownloads(downloads, groupBy) {
const groups = {};
downloads.forEach(dl => {
let key = 'Ungrouped';
switch (groupBy) {
case 'series':
key = extractSeriesName(dl.filename);
break;
case 'status':
key = translateStatus(dl.status);
break;
case 'day':
key = getDayString(dl.created_at);
break;
default:
key = 'Tous';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(dl);
});
return groups;
}
// Clear completed downloads
async function clearCompleted() {
const completed = allDownloads.filter(dl => dl.status === 'completed');
if (completed.length === 0) {
alert('Aucun téléchargement terminé à supprimer');
return;
}
if (!confirm(`Supprimer ${completed.length} téléchargement(s) terminé(s) ?`)) {
return;
}
for (const dl of completed) {
await fetch(`${API_BASE}/download/${dl.id}`, { method: 'DELETE' });
}
loadDownloads();
}
// Search Anime across all providers
async function searchAnime() {
const query = document.getElementById('searchInput').value.trim();
const lang = document.getElementById('langSelect').value;
const includeMetadata = document.getElementById('includeMetadata').checked;
if (!query) {
alert('Veuillez entrer un nom d\'anime');
return;
}
const resultsContainer = document.getElementById('searchResults');
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
try {
const response = await fetch(`${API_BASE}/anime/search?q=${encodeURIComponent(query)}&lang=${lang}&include_metadata=${includeMetadata}`);
const data = await response.json();
displaySearchResults(data, lang);
} catch (error) {
console.error('Error:', error);
resultsContainer.innerHTML = '<div class="no-results">Erreur lors de la recherche</div>';
}
}
async function displaySearchResults(data, lang) {
const resultsContainer = document.getElementById('searchResults');
const providers = await getProvidersInfo();
let totalResults = 0;
let html = '';
for (const [providerId, results] of Object.entries(data.results)) {
if (results && results.length > 0) {
totalResults += results.length;
results.forEach(anime => {
const providerInfo = providers.anime_providers[providerId];
// Build metadata HTML if available
let metadataHtml = '';
if (anime.metadata) {
const meta = anime.metadata;
let metaParts = [];
if (meta.release_year) metaParts.push(`📅 ${meta.release_year}`);
if (meta.rating) metaParts.push(`${meta.rating}`);
if (meta.genres && meta.genres.length > 0) metaParts.push(`🏷️ ${meta.genres.slice(0, 3).join(', ')}`);
if (meta.total_episodes) metaParts.push(`📺 ${meta.total_episodes} épisodes`);
if (meta.status) metaParts.push(`📡 ${meta.status === 'Ongoing' ? 'En cours' : 'Terminé'}`);
if (metaParts.length > 0) {
metadataHtml = `
<div class="anime-metadata">
${metaParts.join(' • ')}
</div>
`;
}
// Add synopsis if available (expandable)
if (meta.synopsis) {
metadataHtml += `
<details class="anime-synopsis">
<summary>📖 Synopsis</summary>
<p>${escapeHtml(meta.synopsis)}</p>
</details>
`;
}
}
html += `
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
</div>
${metadataHtml}
<div class="anime-card-actions">
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
<option value="">Charger les épisodes...</option>
</select>
</div>
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
<button class="btn-primary" onclick="downloadAnimeEpisode('${encodeURIComponent(anime.url)}', '${providerId}', '${lang}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
<button class="btn-primary" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="downloadEntireSeason('${encodeURIComponent(anime.url)}', '${lang}')" title="Télécharger toute la saison">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Toute la saison
</button>
</div>
</div>
`;
// Auto-load episodes
setTimeout(() => {
loadEpisodesForAnime(providerId, encodeURIComponent(anime.url), lang, null);
}, 100);
});
}
}
if (totalResults === 0) {
html = '<div class="no-results">Aucun résultat trouvé</div>';
}
resultsContainer.innerHTML = html;
}
async function loadEpisodesForAnime(providerId, encodedUrl, lang, selectElement) {
const url = decodeURIComponent(encodedUrl);
const selectId = `episodes-${providerId}-${encodedUrl}`;
const actionsId = `actions-${providerId}-${encodedUrl}`;
if (!selectElement) {
selectElement = document.getElementById(selectId);
}
if (!selectElement) return;
selectElement.innerHTML = '<option value="">Chargement...</option>';
try {
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=${lang}`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
selectElement.appendChild(option);
});
// Show download buttons immediately (season button is always useful)
const actionsDiv = document.getElementById(actionsId);
actionsDiv.style.display = 'flex';
// Store the selected episode URL for download
selectElement.dataset.animeUrl = url;
selectElement.dataset.providerId = providerId;
selectElement.dataset.lang = lang;
} else {
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
selectElement.disabled = true;
// Add warning message
const card = document.getElementById(`anime-${providerId}-${encodedUrl}`);
if (card) {
const warning = document.createElement('div');
warning.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 100, 100, 0.2); border-radius: 6px; font-size: 12px; color: #ff6b6b;';
warning.textContent = '⚠️ Aucun épisode trouvé. Essayez une recherche exacte ou un autre fournisseur.';
card.appendChild(warning);
}
}
} catch (error) {
console.error('Error loading episodes:', error);
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
async function downloadAnimeEpisode(encodedUrl, providerId, lang) {
const url = decodeURIComponent(encodedUrl);
const selectId = `episodes-${providerId}-${encodedUrl}`;
const selectElement = document.getElementById(selectId);
const episodeUrl = selectElement.value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
// Show success message and refresh downloads
loadDownloads();
alert('Téléchargement démarré!');
// Keep the select available for more downloads, just reset the selection
selectElement.value = '';
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du démarrage du téléchargement');
}
}
async function downloadEntireSeason(encodedUrl, lang) {
const url = decodeURIComponent(encodedUrl);
if (!confirm(`⚠️ Attention: Vous allez télécharger toute la saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
return;
}
try {
const response = await fetch(`${API_BASE}/anime/download-season?url=${encodeURIComponent(url)}&lang=${lang}`, {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
loadDownloads();
alert(`${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
} else {
const error = await response.json();
alert(`Erreur: ${error.detail || 'Impossible de démarrer le téléchargement de la saison'}`);
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du démarrage du téléchargement de la saison');
}
}
async function getProvidersInfo() {
if (!searchResultsCache.providers) {
const response = await fetch(`${API_BASE}/providers`);
searchResultsCache.providers = await response.json();
}
return searchResultsCache.providers;
}
// Load providers dynamically
async function loadProviders() {
try {
const response = await fetch(`${API_BASE}/providers`);
const data = await response.json();
// Update anime tabs
const animeTabsContainer = document.querySelector('.tabs');
const existingTabs = animeTabsContainer.querySelectorAll('.tab[data-tab-type="anime"]');
existingTabs.forEach(tab => tab.remove());
// Add anime provider tabs
Object.entries(data.anime_providers).forEach(([id, provider]) => {
// Check if tab doesn't exist
if (!document.querySelector(`.tab[data-provider="${id}"]`)) {
const button = document.createElement('button');
button.className = 'tab';
button.setAttribute('data-tab-type', 'anime');
button.setAttribute('data-provider', id);
button.innerHTML = `${provider.icon} ${provider.name}`;
button.onclick = () => switchTab(`anime-${id}`);
animeTabsContainer.appendChild(button);
// Create corresponding tab content
const tabContent = document.createElement('div');
tabContent.id = `tab-anime-${id}`;
tabContent.className = 'tab-content';
tabContent.innerHTML = createAnimeTabContent(id, provider);
document.querySelector('.container').insertBefore(
tabContent,
document.getElementById('downloadsList')
);
}
});
// Update supported hosts badges
const hostsContainer = document.querySelector('.supported-hosts');
hostsContainer.innerHTML = '';
Object.values(data.file_hosts).forEach(host => {
const badge = document.createElement('span');
badge.className = 'host-badge';
badge.textContent = `${host.icon} ${host.name}`;
hostsContainer.appendChild(badge);
});
} catch (error) {
console.error('Error loading providers:', error);
}
}
function createAnimeTabContent(providerId, provider) {
return `
<div class="url-form">
<div class="anime-input-group">
<input
type="text"
id="${providerId}UrlInput"
placeholder="URL de l'anime (ex: ${provider.url_pattern})"
>
<button type="button" class="btn-primary" onclick="loadProviderEpisodes('${providerId}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Charger
</button>
</div>
<div id="${providerId}EpisodeSelector" class="anime-select-group" style="display:none;">
<select id="${providerId}EpisodeSelect">
<option value="">Sélectionner un épisode</option>
</select>
<button type="button" class="btn-primary" onclick="downloadProviderEpisode('${providerId}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
</div>
</div>
`;
}
async function loadProviderEpisodes(providerId) {
const animeUrl = document.getElementById(`${providerId}UrlInput`).value;
if (!animeUrl) {
alert('Veuillez entrer une URL d\'anime');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(animeUrl)}`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
const select = document.getElementById(`${providerId}EpisodeSelect`);
select.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
select.appendChild(option);
});
document.getElementById(`${providerId}EpisodeSelector`).style.display = 'flex';
} else {
alert('Aucun épisode trouvé');
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du chargement des épisodes');
}
}
async function downloadProviderEpisode(providerId) {
const episodeUrl = document.getElementById(`${providerId}EpisodeSelect`).value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
document.getElementById(`${providerId}EpisodeSelect`).value = '';
loadDownloads();
}
} catch (error) {
console.error('Error:', error);
}
}
// Tab switching
function switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab
document.getElementById(`tab-${tabName}`).classList.add('active');
event.target.classList.add('active');
}
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('urlInput').value;
try {
const response = await fetch(`${API_BASE}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (response.ok) {
document.getElementById('urlInput').value = '';
loadDownloads();
}
} catch (error) {
console.error('Error:', error);
}
});
// Anime functions
async function loadEpisodes() {
const animeUrl = document.getElementById('animeUrlInput').value;
if (!animeUrl) {
alert('Veuillez entrer une URL d\'anime');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(animeUrl)}`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
const select = document.getElementById('episodeSelect');
select.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
select.appendChild(option);
});
document.getElementById('episodeSelector').style.display = 'flex';
currentAnimeUrl = animeUrl;
} else {
alert('Aucun épisode trouvé');
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du chargement des épisodes');
}
}
async function downloadSelectedEpisode() {
const episodeUrl = document.getElementById('episodeSelect').value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
document.getElementById('episodeSelect').value = '';
loadDownloads();
}
} catch (error) {
console.error('Error:', error);
}
}
// Anime-Ultime functions
async function loadAnimeUltimeEpisodes() {
const animeUrl = document.getElementById('animeultimeUrlInput').value;
if (!animeUrl) {
alert('Veuillez entrer une URL d\'anime');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(animeUrl)}`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
const select = document.getElementById('animeultimeEpisodeSelect');
select.innerHTML = '<option value="">Sélectionner un épisode</option>';
data.episodes.forEach(ep => {
const option = document.createElement('option');
option.value = ep.url;
option.textContent = `Épisode ${ep.episode}`;
select.appendChild(option);
});
document.getElementById('animeultimeEpisodeSelector').style.display = 'flex';
} else {
alert('Aucun épisode trouvé');
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du chargement des épisodes');
}
}
async function downloadAnimeUltimeEpisode() {
const episodeUrl = document.getElementById('animeultimeEpisodeSelect').value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
document.getElementById('animeultimeEpisodeSelect').value = '';
loadDownloads();
}
} catch (error) {
console.error('Error:', error);
}
}
async function loadDownloads() {
const response = await fetch(`${API_BASE}/downloads`);
const data = await response.json();
allDownloads = data.downloads; // Store all downloads
updateStats(); // Update statistics
filterDownloads(); // Apply current filters
}
function updateStats() {
const stats = {
total: allDownloads.length,
downloading: allDownloads.filter(d => d.status === 'downloading').length,
paused: allDownloads.filter(d => d.status === 'paused').length,
completed: allDownloads.filter(d => d.status === 'completed').length,
failed: allDownloads.filter(d => d.status === 'failed').length
};
document.getElementById('downloadsStats').innerHTML = `
<div class="stat-item">Total: <span class="stat-count">${stats.total}</span></div>
${stats.downloading > 0 ? `<div class="stat-item">En cours: <span class="stat-count" style="color: #00d9ff;">${stats.downloading}</span></div>` : ''}
${stats.paused > 0 ? `<div class="stat-item">En pause: <span class="stat-count" style="color: #ffa500;">${stats.paused}</span></div>` : ''}
${stats.completed > 0 ? `<div class="stat-item">Terminés: <span class="stat-count" style="color: #00ff88;">${stats.completed}</span></div>` : ''}
${stats.failed > 0 ? `<div class="stat-item">Échoués: <span class="stat-count" style="color: #ff4444;">${stats.failed}</span></div>` : ''}
`;
}
function displayDownloads(downloads, groupBy = 'none') {
const container = document.getElementById('downloadsList');
if (downloads.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<p>Aucun téléchargement trouvé</p>
</div>
`;
return;
}
// Group downloads if needed
if (groupBy && groupBy !== 'none') {
const groups = groupDownloads(downloads, groupBy);
const groupNames = Object.keys(groups);
// Sort group names
groupNames.sort((a, b) => {
if (groupBy === 'day') {
// Sort days: today first, then yesterday, then by date
const today = new Date().toDateString();
const yesterday = new Date(Date.now() - 86400000).toDateString();
// Get actual dates from downloads to compare
return a.localeCompare(b);
}
return a.localeCompare(b);
});
// Display grouped downloads
let html = '';
groupNames.forEach(groupName => {
const groupDownloads = groups[groupName];
html += `
<div class="downloads-group">
<div class="downloads-group-header" onclick="toggleGroup(this)">
<div class="downloads-group-title">${escapeHtml(groupName)}</div>
<div class="downloads-group-count">${groupDownloads.length}</div>
</div>
<div class="downloads-group-items">
${groupDownloads.map(dl => renderDownloadItem(dl)).join('')}
</div>
</div>
`;
});
container.innerHTML = html;
} else {
// Display flat list
container.innerHTML = downloads.map(dl => renderDownloadItem(dl)).join('');
}
}
function renderDownloadItem(dl) {
return `
<div class="download-item">
<div class="download-header">
<div class="filename">${escapeHtml(dl.filename)}</div>
<span class="status status-${dl.status}">${translateStatus(dl.status)}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${dl.progress}%"></div>
</div>
<div class="download-info">
<span>${formatBytes(dl.downloaded_bytes)}${dl.total_bytes ? ' / ' + formatBytes(dl.total_bytes) : ''}</span>
<span>${dl.speed > 0 ? formatSpeed(dl.speed) : ''}</span>
</div>
<div class="download-actions">
${dl.status === 'downloading' ? `
<button class="btn-small btn-pause" onclick="pauseDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Pause
</button>
<button class="btn-small btn-cancel" onclick="cancelDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Annuler
</button>
` : ''}
${dl.status === 'paused' ? `
<button class="btn-small btn-resume" onclick="resumeDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Reprendre
</button>
<button class="btn-small btn-cancel" onclick="cancelDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Annuler
</button>
` : ''}
${dl.status === 'completed' ? `
<button class="btn-small btn-download" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="watchVideo('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Regarder
</button>
<button class="btn-small btn-download" onclick="downloadFile('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
<button class="btn-small btn-cancel" onclick="cancelDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Supprimer
</button>
` : ''}
${dl.status === 'failed' ? `
<button class="btn-small btn-cancel" onclick="cancelDownload('${dl.id}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Supprimer
</button>
` : ''}
</div>
${dl.error ? `<div class="error-message">${escapeHtml(dl.error)}</div>` : ''}
</div>
`;
}
function toggleGroup(header) {
const items = header.nextElementSibling;
items.style.display = items.style.display === 'none' ? 'flex' : 'none';
header.classList.toggle('collapsed');
}
async function pauseDownload(id) {
await fetch(`${API_BASE}/download/${id}/pause`, { method: 'POST' });
loadDownloads();
}
async function resumeDownload(id) {
await fetch(`${API_BASE}/download/${id}/resume`, { method: 'POST' });
loadDownloads();
}
async function cancelDownload(id) {
if (confirm('Êtes-vous sûr ?')) {
await fetch(`${API_BASE}/download/${id}`, { method: 'DELETE' });
loadDownloads();
}
}
function downloadFile(id) {
window.open(`${API_BASE}/download/${id}/file`, '_blank');
}
function watchVideo(id) {
window.open(`/player/${id}`, '_blank');
}
function translateStatus(status) {
const translations = {
'pending': 'En attente',
'downloading': 'Téléchargement',
'paused': 'En pause',
'completed': 'Terminé',
'failed': 'Échoué',
'cancelled': 'Annulé'
};
return translations[status] || status;
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSecond) {
return formatBytes(bytesPerSecond) + '/s';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize: Load providers and start downloads refresh
loadProviders();
loadDownloads();
setInterval(loadDownloads, 1000);
</script>
</body>
</html>