feat: Add series TV support with Vidzy HLS downloads and duplicate prevention

Major improvements:
- Series TV support via FS7 provider with dedicated search endpoint
- Vidzy downloader now uses Playwright for JS obfuscation and ffmpeg for HLS streams
- Episode filenames properly named (Series Title - Episode X) instead of master.m3u8.mp4
- Duplicate download prevention: checks existing tasks before creating new ones
- Removed host preference system in favor of intelligent URL-based detection

Technical changes:
- Vidzy: Added Playwright extraction and M3U8→MP4 conversion with ffmpeg
- FS7: Episodes now use pipe format (video_url|series_url|episode_title)
- DownloadManager: Extract target_filename from pipe URL and prevent duplicates
- UI: New Series tab with search, recommendations, and releases sections
- Anime-Sama: Removed hardcoded host preferences, uses site's URL order

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
root
2026-01-25 20:42:29 +00:00
parent 5e50081b58
commit c1c31d7685
17 changed files with 938 additions and 219 deletions
+13
View File
@@ -338,8 +338,21 @@ async function handleSearch() {
await searchAnimeDetails(query);
}
// Handle anime search (new dedicated function)
async function handleAnimeSearch() {
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
if (!searchInput) return;
const query = searchInput.value.trim();
if (!query) return;
// Use the new anime details search
await searchAnimeDetails(query);
}
// Ensure global scope
window.handleSearch = handleSearch;
window.handleAnimeSearch = handleAnimeSearch;
/**
* Handle direct download form submission
+12
View File
@@ -149,3 +149,15 @@ async function cancelDownload(id) {
return await response.json();
}
// Make functions available globally
window.getProvidersInfo = getProvidersInfo;
window.searchAnime = searchAnime;
window.loadEpisodes = loadEpisodes;
window.downloadEpisode = downloadEpisode;
window.downloadSeason = downloadSeason;
window.startDownload = startDownload;
window.getDownloads = getDownloads;
window.pauseDownload = pauseDownload;
window.resumeDownload = resumeDownload;
window.cancelDownload = cancelDownload;
+15 -9
View File
@@ -17,12 +17,22 @@ document.addEventListener('DOMContentLoaded', () => {
* Initialize form event listeners
*/
function initializeForms() {
// Search form
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('keypress', (e) => {
// Anime search form
const animeSearchInput = document.getElementById('animeSearchInput');
if (animeSearchInput) {
animeSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSearch();
handleAnimeSearch();
}
});
}
// Series search form
const seriesSearchInput = document.getElementById('seriesSearchInput');
if (seriesSearchInput) {
seriesSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSeriesSearch();
}
});
}
@@ -209,10 +219,6 @@ function switchTab(tabName) {
if (tabType === 'home' && tabName === 'home') {
btn.classList.add('active');
} else if (tabType === 'search' && tabName === 'search') {
btn.classList.add('active');
} else if (tabType === 'direct' && tabName === 'direct') {
btn.classList.add('active');
} else if (tabType === 'anime' && tabName === 'anime') {
// Static anime tab
btn.classList.add('active');
+6 -6
View File
@@ -253,20 +253,20 @@ function getRatingColor(score) {
return 'linear-gradient(45deg, #666, #888)';
}
// Search anime on providers (redirects to search tab)
// Search anime on providers (redirects to anime tab)
function searchAnimeOnProviders(title) {
// Switch to search tab
switchTab('search');
// Switch to anime tab
switchTab('anime');
// Fill search input
const searchInput = document.getElementById('searchInput');
const searchInput = document.getElementById('animeSearchInput');
if (searchInput) {
searchInput.value = title;
// Trigger search
setTimeout(() => {
if (typeof searchAnime === 'function') {
searchAnime();
if (typeof handleAnimeSearch === 'function') {
handleAnimeSearch();
}
}, 300);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Series search functionality for FS7
*/
// Handle series search
async function handleSeriesSearch() {
const searchInput = document.getElementById('seriesSearchInput');
const resultsContainer = document.getElementById('seriesSearchResults');
if (!searchInput || !resultsContainer) return;
const query = searchInput.value.trim();
if (!query) {
alert('Veuillez entrer un nom de série');
return;
}
try {
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche de séries TV en cours...</div>';
// Search on series providers using the dedicated endpoint
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(query)}&lang=vf`);
const data = await response.json();
if (data.results && data.results['fs7'] && data.results['fs7'].length > 0) {
const series = data.results['fs7'];
let html = `
<div class="streaming-results-header">
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
series.forEach(s => {
let coverImage = s.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
} else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
html += `
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(s.title)}</div>
<div class="anime-card-provider">📺 French Stream</div>
</div>
${coverImage ? `
<div style="text-align: center; margin: 10px 0;">
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'">
</div>
` : ''}
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
📥 Voir les épisodes
</button>
</div>
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
</div>
`;
});
html += '</div>';
resultsContainer.innerHTML = html;
} else {
resultsContainer.innerHTML = `
<div class="no-results">
<p>❌ Aucune série trouvée pour "${escapeHtml(query)}"</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Essayez avec un autre titre ou vérifiez l'orthographe
</p>
</div>`;
}
} catch (error) {
console.error('Error searching series:', error);
resultsContainer.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors de la recherche</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
</div>`;
}
}
// Load series episodes directly without redirecting to search
async function loadSeriesEpisodesDirect(url, title) {
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
if (!episodesContainer) return;
try {
episodesContainer.innerHTML = '<div class="loading-spinner">Chargement des épisodes...</div>';
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
let html = `
<div style="margin-top: 15px;">
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
📺 Sélectionner un épisode:
</label>
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;">
<option value="">Sélectionner un épisode</option>
${data.episodes.map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
`).join('')}
</select>
<button class="btn-primary" style="margin-top: 10px; width: 100%;" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
<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 l'épisode
</button>
</div>
`;
episodesContainer.innerHTML = html;
} else {
episodesContainer.innerHTML = '<div class="no-results" style="margin-top: 10px;">Aucun épisode disponible</div>';
}
} catch (error) {
console.error('Error loading episodes:', error);
episodesContainer.innerHTML = `<div class="no-results" style="margin-top: 10px; color: #ff6b6b;">Erreur: ${error.message}</div>`;
}
}
// Download series episode
async function downloadSeriesEpisode(url, title) {
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
if (!select || !select.value) {
alert('Veuillez sélectionner un épisode');
return;
}
const episodeUrl = select.value;
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
alert(`✅ Téléchargement démarré pour "${title}"`);
// Refresh downloads
if (typeof loadDownloads === 'function') {
loadDownloads();
}
} else {
const error = await response.json();
const errorMessage = error.detail
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
: 'Impossible de démarrer le téléchargement';
alert(`❌ Erreur: ${errorMessage}`);
}
} catch (error) {
console.error('Download error:', error);
alert(`❌ Erreur lors du téléchargement: ${error.message}`);
}
}
// Make functions available globally
window.handleSeriesSearch = handleSeriesSearch;
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
window.downloadSeriesEpisode = downloadSeriesEpisode;
+204 -9
View File
@@ -2,6 +2,158 @@
* New tabs functionality
*/
// Render series recommendation card (same design as anime recommendations)
function renderSeriesRecommendationCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal recommendation-card">
<div class="recommendation-badge">🎺 Série TV populaire</div>
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series episodes (redirects to series tab with search)
async function loadSeriesEpisodes(url, title) {
// Switch to series tab
switchTab('series');
// Fill search input with the series title
const searchInput = document.getElementById('seriesSearchInput');
if (searchInput) {
searchInput.value = title;
// Trigger search
setTimeout(() => {
if (typeof handleSeriesSearch === 'function') {
handleSeriesSearch();
}
}, 300);
}
}
// Render series release card (same design as anime releases)
function renderSeriesReleaseCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal release-card">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV • Nouveau
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series recommendations for the Series tab
async function loadSeriesRecommendations() {
try {
const container = document.getElementById('seriesRecommendationsList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des recommandations séries...</div>';
// Search for popular series from all providers (including FS7)
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders', 'House of the Dragon', 'The Witcher'];
const allSeries = [];
for (const term of searchTerms) {
try {
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="recommendations-carousel">${allSeries.map(series =>
renderSeriesRecommendationCard(series)
).join('')}</div>`;
} else {
container.innerHTML = '<div class="no-results">Aucune recommandation trouvée</div>';
}
} catch (error) {
console.error('Error loading series recommendations:', error);
const container = document.getElementById('seriesRecommendationsList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
}
}
// Load anime releases for the Anime tab
async function loadAnimeReleases() {
try {
@@ -34,23 +186,63 @@ async function loadSeriesReleases() {
const container = document.getElementById('seriesReleasesList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties séries...</div>';
container.innerHTML = '<div class="loading-spinner">Chargement des dernières séries TV...</div>';
// For series, we'll show the same releases but could filter later
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
const data = await response.json();
// Search for popular series from all providers (including FS7)
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders'];
const allSeries = [];
if (data.releases && data.releases.length > 0) {
container.innerHTML = `<div class="releases-carousel">${data.releases.map(anime =>
renderReleaseCard({...anime, title: anime.title + ' [Série]'})
for (const term of searchTerms) {
try {
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
// Add results from other providers if needed
for (const [provider, results] of Object.entries(data.results)) {
if (provider !== 'fs7' && results.length > 0 && allSeries.length < 12) {
allSeries.push(...results.slice(0, 1));
}
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="releases-carousel">${allSeries.map(series =>
renderSeriesReleaseCard(series)
).join('')}</div>`;
} else {
container.innerHTML = '<div class="no-results">Aucune sortie trouvée</div>';
container.innerHTML = `
<div class="no-results">
<p>Aucune série trouvée</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Utilisez l'onglet "Recherche" pour trouver des séries spécifiques
</p>
</div>`;
}
} catch (error) {
console.error('Error loading series releases:', error);
const container = document.getElementById('seriesReleasesList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
if (container) {
container.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors du chargement des séries</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
🔄 Réessayer
</button>
</div>`;
}
}
}
@@ -184,6 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
} else if (tabName === 'series') {
if (!window.seriesTabLoaded) {
loadSeriesRecommendations();
loadSeriesReleases();
window.seriesTabLoaded = true;
}
@@ -200,6 +393,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Make functions available globally
window.loadSeriesEpisodes = loadSeriesEpisodes;
window.loadSeriesRecommendations = loadSeriesRecommendations;
window.loadAnimeReleases = loadAnimeReleases;
window.loadSeriesReleases = loadSeriesReleases;
window.loadProvidersGrid = loadProvidersGrid;