refactor: migrate main.py to modular routers and add project roadmap
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Migrated monolithic main.py to feature-scoped routers in app/routers/
- Added GEMINI.md for project context and AI instructional guidelines
- Updated README.md with a comprehensive modernization plan (SQL migration, robust scraping DSL, frontend modernization)
- Improved authentication with cookie support and modular JS
- Updated test suite and documentation
This commit is contained in:
root
2026-03-24 10:12:04 +00:00
parent 1b5d7f9238
commit d4d8d8a3b6
42 changed files with 4518 additions and 2426 deletions
+519
View File
@@ -0,0 +1,519 @@
"""
Anime and series search routes for Ohm Stream Downloader API.
Endpoints:
- GET /api/anime/search - Search across all anime providers
- GET /api/series/search - Search across all TV series providers
- GET /api/anime/metadata - Get detailed metadata for a specific anime
- GET /api/anime/episodes - Get list of episodes for an anime
- GET /api/anime/providers - Get list of anime providers
- GET /api/anime-sama/search - Search for anime on anime-sama (legacy)
- POST /api/anime/download - Download an anime episode
- GET /api/anime/frieren/episodes - Get Frieren episodes from local database
- POST /api/anime/frieren/download - Download Frieren episode from local database
- POST /api/anime/download-season - Download all episodes of a season
- GET /api/anime/seasons - Get list of seasons for an anime
- GET /api/anime/mal/search - Search for anime on MyAnimeList
- GET /api/anime/mal/{mal_id} - Get full details by MyAnimeList ID
- POST /api/translate - Translate text from English to French
"""
import json
import re
import time
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from app.download_manager import DownloadManager
from app.downloaders import (
AnimeSamaDownloader,
AnimeUltimeDownloader,
NekoSamaDownloader,
VostfreeDownloader,
get_downloader,
)
from app.models import DownloadRequest
from app.providers import get_anime_providers, get_series_providers
router = APIRouter(prefix="/api", tags=["anime"])
def get_download_manager() -> DownloadManager:
"""Get the download manager instance from main app"""
from main import download_manager
return download_manager
# ==================== ANIME SEARCH ====================
@router.get("/anime/search")
async def search_anime_unified(
q: str,
lang: str = "vostfr",
include_metadata: bool = False,
):
"""
Search across all anime providers
Args:
q: Search query
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata (slower but more detailed)
"""
import asyncio
print(
f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})"
)
start_time = time.time()
results = {}
# Create downloader instances
downloaders = {
"anime-sama": AnimeSamaDownloader(),
"anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader(),
}
# Generate search query variations for better matching
search_queries = [q]
# Add fallback queries if original has spaces
if " " in q or "-" in q:
normalized = re.sub(r"[\s\-–—_:]+", "", q)
if normalized != q and len(normalized) >= 4:
search_queries.append(normalized)
first_word = q.split()[0] if q.split() else None
if first_word and len(first_word) >= 4:
search_queries.append(first_word)
print(f"[SEARCH] Query variations: {search_queries}")
# Search with fallback queries
all_search_tasks = []
all_provider_ids = []
for search_query in search_queries:
print(f"[SEARCH] Trying query variant: '{search_query}'")
for provider_id, provider in get_anime_providers().items():
if provider_id in downloaders:
downloader = downloaders[provider_id]
print(
f"[SEARCH] Queueing search on {provider_id} for '{search_query}'..."
)
all_search_tasks.append(
{
"query": search_query,
"provider_id": provider_id,
"task": downloader.search_anime(
search_query, lang, include_metadata=include_metadata
),
}
)
all_provider_ids.append(provider_id)
print(f"[SEARCH] Waiting for {len(all_search_tasks)} searches...")
search_results = await asyncio.gather(
*[t["task"] for t in all_search_tasks], return_exceptions=True
)
# Process results
seen_urls = {}
for task_info, result in zip(all_search_tasks, search_results):
provider_id = task_info["provider_id"]
search_query = task_info["query"]
if isinstance(result, Exception):
print(
f"[SEARCH] {provider_id} (query: '{search_query}') error: {str(result)}"
)
elif result:
print(
f"[SEARCH] {provider_id} (query: '{search_query}') found {len(result)} results"
)
if provider_id not in results:
results[provider_id] = []
provider_results = results[provider_id]
for item in result:
url = item.get("url", "")
if url and url not in seen_urls:
seen_urls[url] = True
if search_query.lower() == q.lower():
item["_relevance_boost"] = 1.0
else:
item["_relevance_boost"] = 0.5
provider_results.append(item)
else:
print(f"[SEARCH] {provider_id} (query: '{search_query}') no results")
# Sort results by relevance
for provider_id in results:
results[provider_id].sort(
key=lambda x: (
-x.get("_relevance_boost", 0),
(x.get("title") or "").lower().find(q.lower()),
)
)
for item in results[provider_id]:
item.pop("_relevance_boost", None)
# Remove providers with empty results
results = {k: v for k, v in results.items() if v}
elapsed = time.time() - start_time
print(
f"[SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n"
)
return {
"query": q,
"lang": lang,
"include_metadata": include_metadata,
"results": results,
}
@router.get("/series/search")
async def search_series_unified(
q: str,
lang: str = "vf",
):
"""
Search across all TV series providers (FS7, etc.)
"""
import asyncio
from app.downloaders.series_sites import FS7Downloader
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
start_time = time.time()
results = {}
series_downloaders = {"fs7": FS7Downloader()}
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)
print(f"[SERIES SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
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}
@router.get("/anime/metadata")
async def get_anime_metadata(url: str):
"""Get detailed metadata for a specific anime"""
try:
downloader = get_downloader(url)
if hasattr(downloader, "get_anime_metadata"):
metadata = await downloader.get_anime_metadata(url)
return {"url": url, "metadata": metadata}
else:
raise HTTPException(
status_code=400,
detail=f"Downloader for {url} does not support metadata extraction",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anime/episodes")
async def get_anime_episodes(
url: str,
lang: str = "vostfr",
):
"""Get list of episodes for an anime"""
downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang)
return {"url": url, "lang": lang, "episodes": episodes}
@router.get("/anime/providers")
async def get_anime_providers_list():
"""Get list of anime providers with info"""
return {"providers": get_anime_providers()}
# ==================== ANIME-SAMA SPECIFIC ====================
@router.get("/anime-sama/search")
async def search_anime_sama(
q: str,
lang: str = "vostfr",
):
"""Search for anime on anime-sama"""
downloader = AnimeSamaDownloader()
results = await downloader.search_anime(q, lang)
return {"query": q, "lang": lang, "results": results}
@router.post("/anime/download")
async def download_anime_episode(
url: str,
background_tasks: BackgroundTasks,
episode: str | None = None,
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Download an anime episode"""
if episode and "episode-" not in url and "|" not in url:
url = f"{url.rstrip('/')}/episode-{episode}"
request = DownloadRequest(url=url)
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
# ==================== FRIEREN LEGACY ENDPOINTS ====================
@router.get("/anime/frieren/episodes")
async def get_frieren_episodes():
"""Get Frieren episodes from local database"""
try:
with open("app/frieren_episodes.json", "r") as f:
data = json.load(f)
return data
except Exception as e:
raise HTTPException(status_code=404, detail=f"Episodes not found: {e}")
@router.post("/anime/frieren/download")
async def download_frieren_episode(
season: int,
episode: str,
background_tasks: BackgroundTasks,
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Download Frieren episode from local database"""
try:
with open("app/frieren_episodes.json", "r") as f:
data = json.load(f)
season_key = str(season)
if season_key not in data["seasons"]:
raise HTTPException(status_code=404, detail=f"Season {season} not found")
season_data = data["seasons"][season_key]
ep_data = next(
(ep for ep in season_data["episodes"] if ep["episode"] == episode), None
)
if not ep_data:
raise HTTPException(
status_code=404,
detail=f"Episode {episode} not found in season {season}",
)
url = ep_data["sibnet_url"]
filename = f"Frieren - S{season} - Episode {episode}.mp4"
request = DownloadRequest(url=url, filename=filename)
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
# ==================== DOWNLOAD SEASON ====================
@router.post("/anime/download-season")
async def download_anime_season(
url: str,
background_tasks: BackgroundTasks,
lang: str = "vostfr",
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Download all episodes of an anime season"""
downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang)
if not episodes:
raise HTTPException(status_code=404, detail="No episodes found")
task_ids = []
for episode in episodes:
request = DownloadRequest(url=episode["url"])
task = download_manager.create_task(request)
task_ids.append(task.id)
background_tasks.add_task(download_manager.start_download, task.id)
return {
"message": f"Started downloading {len(task_ids)} episodes",
"task_ids": task_ids,
"total_episodes": len(episodes),
}
# ==================== SEASONS ====================
@router.get("/anime/seasons")
async def get_anime_seasons(url: str):
"""Get list of seasons for an anime"""
downloader = get_downloader(url)
if hasattr(downloader, "get_seasons"):
seasons = await downloader.get_seasons(url)
if not seasons:
return {"seasons": [], "message": "No seasons found"}
return {"seasons": seasons}
else:
return {
"seasons": [],
"message": "Season information not available for this provider",
}
# ==================== MYANIMELIST INTEGRATION ====================
@router.get("/anime/mal/search")
async def search_anime_mal_details(
q: str = Query(..., description="Anime search query"),
limit: int = Query(5, description="Number of results"),
):
"""Search for anime on MyAnimeList and get full details"""
from app.recommendations import AnimeReleasesFetcher
fetcher = AnimeReleasesFetcher()
try:
search_results = await fetcher.search_anime(q, limit=limit)
if not search_results:
return {"anime": None, "message": "No anime found"}
main_anime = search_results[0]
anime_details = await fetcher.get_anime_details(main_anime["mal_id"])
alternatives = search_results[1:] if len(search_results) > 1 else []
return {
"anime": anime_details,
"alternatives": alternatives,
"total_results": len(search_results),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
await fetcher.close()
@router.get("/anime/mal/{mal_id}")
async def get_anime_by_id(mal_id: int):
"""Get full details of an anime by its MyAnimeList ID"""
from app.recommendations import AnimeReleasesFetcher
fetcher = AnimeReleasesFetcher()
try:
anime_details = await fetcher.get_anime_details(mal_id)
if not anime_details:
raise HTTPException(status_code=404, detail="Anime not found")
return anime_details
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
await fetcher.close()
# ==================== TRANSLATION ====================
@router.post("/translate")
async def translate_text(request: Request):
"""Translate text from English to French using Google Translate"""
import httpx
from logging import getLogger
logger = getLogger(__name__)
try:
body = await request.json()
text = body.get("text", "")
if not text:
raise HTTPException(status_code=400, detail="Text is required")
text = text[:5000]
async with httpx.AsyncClient(timeout=30.0) as client:
url = "https://translate.googleapis.com/translate_a/single"
params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text}
logger.info(f"Translation request for text length: {len(text)}")
response = await client.get(url, params=params)
logger.info(f"Translation API response status: {response.status_code}")
if response.status_code == 200:
data = response.json()
if data and len(data) > 0 and data[0]:
translated_text = "".join([item[0] for item in data[0] if item[0]])
if translated_text:
logger.info(
f"Translation successful, length: {len(translated_text)}"
)
return {"translatedText": translated_text, "status": "success"}
logger.warning(
f"Unexpected Google Translate response structure: {data}"
)
raise HTTPException(status_code=500, detail="Translation failed")
except HTTPException:
raise
except Exception as e:
logger.error(f"Translation error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")