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
+75 -2
View File
@@ -259,8 +259,9 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
"""
import time
import asyncio
from app.providers import get_anime_providers
from app.providers import get_anime_providers, get_series_providers
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
from app.downloaders.series_sites import FS7Downloader
print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})")
start_time = time.time()
@@ -275,7 +276,12 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
"vostfree": VostfreeDownloader()
}
# Search across all providers in parallel with timeout
# Create series downloader instances
series_downloaders = {
"fs7": FS7Downloader()
}
# Search across all anime providers in parallel with timeout
search_tasks = []
provider_ids = []
@@ -286,6 +292,14 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata))
provider_ids.append(provider_id)
# Search across all series providers in parallel with timeout
for provider_id, provider in get_series_providers().items():
if provider_id in series_downloaders:
downloader = series_downloaders[provider_id]
print(f"[SEARCH] Queueing search on {provider_id} (series)...")
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider
print(f"[SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
@@ -311,6 +325,65 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
}
@app.get("/api/series/search")
async def search_series_unified(q: str, lang: str = "vf"):
"""
Search across all TV series providers (FS7, etc.)
Args:
q: Search query
lang: Language preference (vf, vostfr)
"""
import time
import asyncio
from app.providers import get_series_providers
from app.downloaders.series_sites import FS7Downloader
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
start_time = time.time()
results = {}
# Create series downloader instances
series_downloaders = {
"fs7": FS7Downloader()
}
# Search across all series providers in parallel
search_tasks = []
provider_ids = []
for provider_id, provider in get_series_providers().items():
if provider_id in series_downloaders:
downloader = series_downloaders[provider_id]
print(f"[SERIES SEARCH] Queueing search on {provider_id}...")
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider
print(f"[SERIES SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Combine results
for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception):
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
elif result:
print(f"[SERIES SEARCH] {provider_id} found {len(result)} results")
results[provider_id] = result
else:
print(f"[SERIES SEARCH] {provider_id} no results")
elapsed = time.time() - start_time
print(f"[SERIES SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
return {
"query": q,
"lang": lang,
"results": results
}
@app.get("/api/anime/metadata")
async def get_anime_metadata(url: str):
"""