feat: frontend modernization with HTMX, Alpine.js and Plyr (Phase 3)
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

- Integrated HTMX for server-driven UI updates and fragments
- Adopted Alpine.js for global reactive state and tab management
- Replaced legacy player with Plyr.io for premium streaming experience
- Implemented real-time download polling via HTMX
- Added server-sent Toast notification system
- Fixed navigation and authentication scoping issues
This commit is contained in:
root
2026-03-24 11:10:22 +00:00
parent 2b4cc617cb
commit 5c7116557d
17 changed files with 584 additions and 690 deletions
+1 -5
View File
@@ -3,10 +3,7 @@ Routers package for Ohm Stream Downloader API.
"""
from app.routers.router_auth import router as auth_router
from app.routers.router_downloads import (
router as downloads_router,
legacy_router as downloads_legacy_router,
)
from app.routers.router_downloads import router as downloads_router
from app.routers.router_anime import router as anime_router
from app.routers.router_favorites import router as favorites_router
from app.routers.router_recommendations import router as recommendations_router
@@ -19,7 +16,6 @@ from app.routers.router_root import router as root_router
__all__ = [
"auth_router",
"downloads_router",
"downloads_legacy_router",
"anime_router",
"favorites_router",
"recommendations_router",
+19 -21
View File
@@ -1,20 +1,5 @@
"""
Anime and series search routes for Ohm Stream Downloader API.
Endpoints:
- GET /api/anime/search - Search across all anime providers (Modernized with Kitsu)
- 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/providers/health - Get provider health status
- POST /api/providers/health/check - Trigger health check
- POST /api/anime/download - Download an anime episode
- 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
@@ -22,8 +7,10 @@ import re
import time
import logging
import asyncio
import hashlib
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from app.download_manager import DownloadManager
from app.downloaders import (
@@ -40,6 +27,13 @@ from app.metadata_enrichment import get_metadata_enricher
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["anime"])
templates = Jinja2Templates(directory="templates")
# Add custom filters to Jinja2
def hash_filter(s):
return hashlib.md5(s.encode()).hexdigest()[:10]
templates.env.filters["hash"] = hash_filter
@router.get("/providers/health")
@@ -67,6 +61,7 @@ def get_download_manager() -> DownloadManager:
@router.get("/anime/search")
async def search_anime_unified(
request: Request,
q: str,
lang: str = "vostfr",
include_metadata: bool = False,
@@ -74,6 +69,7 @@ async def search_anime_unified(
"""
Search across all anime providers using MetadataEnricher and health checks.
Results are grouped by provider for legacy UI compatibility.
Returns HTML for HTMX requests.
"""
print(f"\n[SEARCH] Starting modern unified search for '{q}' in {lang}")
start_time = time.time()
@@ -87,7 +83,6 @@ async def search_anime_unified(
# Generic YAML providers
active_generic = providers_manager.get_active_providers()
for provider in active_generic:
print(f"[SEARCH] Queueing generic provider: {provider.name}")
search_tasks.append(provider.search(q))
task_metadata.append({"id": provider.id, "type": "generic"})
@@ -98,20 +93,16 @@ async def search_anime_unified(
"vostfree": VostfreeDownloader(),
}
for pid, dl in legacy_downloaders.items():
print(f"[SEARCH] Queueing legacy provider: {pid}")
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
task_metadata.append({"id": pid, "type": "legacy"})
# 2. Run searches in parallel
print(f"[SEARCH] Waiting for {len(search_tasks)} provider results...")
all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# 3. Organize results by provider
seen_urls = set()
enricher = await get_metadata_enricher()
enrichment_tasks = []
# Map task indices to result slots for re-injection after enrichment
enrichment_mapping = [] # List of (provider_id, index_in_provider_results)
for i, raw_result in enumerate(all_raw_results):
@@ -180,6 +171,13 @@ async def search_anime_unified(
total_found = sum(len(r) for r in results.values())
print(f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} unique results across {len(results)} providers.")
# 6. Return HTML for HTMX or JSON for API
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/anime_search_results.html",
{"request": request, "results": results}
)
return {
"query": q,
"lang": lang,
+50 -97
View File
@@ -2,56 +2,48 @@
Download management routes for Ohm Stream Downloader API.
"""
import os
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from app.download_manager import DownloadManager
from app.models import DownloadRequest, DownloadStatus
from app.utils import is_safe_filename, sanitize_filename
from app.models import DownloadRequest
from app.routers.router_auth import get_current_user_from_token
router = APIRouter(prefix="/api/download", tags=["downloads"])
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
templates = Jinja2Templates(directory="templates")
def get_download_manager() -> DownloadManager:
from main import download_manager
return download_manager
@router.get("")
async def get_downloads(
request: Request,
html: bool = Query(False),
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Get list of all download tasks"""
tasks = download_manager.get_all_tasks()
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/downloads_list.html",
{"request": request, "tasks": tasks}
)
return tasks
@router.post("")
async def create_download(
request: DownloadRequest,
background_tasks: BackgroundTasks,
download_request: DownloadRequest,
download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
):
"""Create a new download task"""
if request.filename:
request.filename = sanitize_filename(request.filename)
if not is_safe_filename(request.filename):
raise HTTPException(
status_code=400,
detail="Invalid filename. Path traversal attempts are not allowed.",
)
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
@router.get("/direct")
async def direct_download(
url: str,
filename: str,
background_tasks: BackgroundTasks,
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Download directly from a video URL with custom filename"""
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}
return download_manager.create_task(download_request)
@router.get("/{task_id}")
@@ -59,7 +51,7 @@ async def get_download_status(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
):
"""Get status of a specific download"""
"""Get status of a specific download task"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
@@ -70,82 +62,43 @@ async def get_download_status(
async def pause_download(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
):
"""Pause a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.pause_download(task_id)
return {"status": "paused"}
"""Pause a download task"""
if download_manager.pause_download(task_id):
return {"status": "success", "message": "Download paused"}
raise HTTPException(status_code=400, detail="Failed to pause download")
@router.post("/{task_id}/resume")
async def resume_download(
task_id: str,
background_tasks: BackgroundTasks,
download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
):
"""Resume a paused download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status == DownloadStatus.PAUSED:
background_tasks.add_task(download_manager.start_download, task_id)
return {"status": "resumed"}
return {"status": "already running or completed"}
"""Resume a paused download task"""
if download_manager.resume_download(task_id):
return {"status": "success", "message": "Download resumed"}
raise HTTPException(status_code=400, detail="Failed to resume download")
@router.delete("/{task_id}")
async def delete_download(
async def cancel_download(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
):
"""Delete/cancel a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.delete_task(task_id)
return {"status": "deleted"}
"""Cancel and delete a download task"""
if download_manager.cancel_download(task_id):
return {"status": "success", "message": "Download cancelled"}
raise HTTPException(status_code=400, detail="Failed to cancel download")
@router.get("/{task_id}/file")
async def download_file(
task_id: str,
@router.post("/cleanup")
async def cleanup_completed(
download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
):
"""Download the completed file"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
task.file_path, filename=task.filename, media_type="application/octet-stream"
)
@router.get("/")
async def list_downloads(
download_manager: DownloadManager = Depends(get_download_manager),
):
"""List all download tasks"""
return {"downloads": download_manager.get_all_tasks()}
# Legacy endpoint for /api/downloads
legacy_router = APIRouter(prefix="/api", tags=["downloads-legacy"])
@legacy_router.get("/downloads")
async def list_all_downloads(
download_manager: DownloadManager = Depends(get_download_manager),
):
"""List all download tasks (legacy endpoint at /api/downloads)"""
return {"downloads": download_manager.get_all_tasks()}
"""Remove all completed tasks from the list"""
count = download_manager.cleanup_tasks()
return {"status": "success", "message": f"Cleaned up {count} tasks"}
+60 -335
View File
@@ -3,9 +3,11 @@ Watchlist management routes for Ohm Stream Downloader API.
"""
import re
import json
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Request, Query
from fastapi.templating import Jinja2Templates
from app.download_manager import DownloadManager
from app.downloaders import get_downloader
@@ -21,30 +23,34 @@ from app.models.watchlist import (
from app.routers.router_auth import get_current_user_from_token
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
templates = Jinja2Templates(directory="templates")
def get_download_manager() -> DownloadManager:
from main import download_manager
return download_manager
@router.post("", response_model=WatchlistItem)
async def add_to_watchlist(
item_data: WatchlistItemCreate,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Add an anime to the watchlist"""
from main import watchlist_manager
try:
item = watchlist_manager.create(current_user.id, item_data)
existing = watchlist_manager.get_by_anime_url(item_data.anime_url, current_user.id)
item = watchlist_manager.add(current_user.id, item_data)
msg = f"'{item.anime_title}' ajouté à la watchlist" if not existing else f"'{item.anime_title}' est déjà dans la watchlist"
toast_type = "success" if not existing else "info"
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": msg, "type": toast_type}})
return item
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@@ -52,21 +58,22 @@ async def add_to_watchlist(
@router.get("", response_model=List[WatchlistItem])
async def get_watchlist(
request: Request,
status: Optional[WatchlistStatus] = None,
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
):
"""Get user's watchlist"""
"""Get user's watchlist (supports HTML for HTMX)"""
from main import watchlist_manager
try:
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
return items
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error getting watchlist: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/watchlist_items_list.html",
{"request": request, "items": items}
)
return items
@router.get("/settings", response_model=WatchlistSettings)
@@ -75,21 +82,13 @@ async def get_watchlist_settings(
):
"""Get global watchlist settings"""
from main import watchlist_manager
try:
settings = watchlist_manager.get_settings()
return settings
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error getting watchlist settings: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return watchlist_manager.get_settings()
@router.put("/settings", response_model=WatchlistSettings)
async def update_watchlist_settings(
settings: WatchlistSettings,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Update global watchlist settings"""
@@ -98,125 +97,11 @@ async def update_watchlist_settings(
try:
updated_settings = watchlist_manager.update_settings(settings)
if auto_download_scheduler.is_running():
auto_download_scheduler.restart()
auto_download_scheduler.update_interval(updated_settings.check_interval_hours)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres de la watchlist mis à jour", "type": "success"}})
return updated_settings
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error updating watchlist settings: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats")
async def get_watchlist_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Get watchlist statistics"""
from main import watchlist_manager
try:
stats = watchlist_manager.get_stats(user_id=current_user.id)
return stats
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error getting watchlist stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check-all")
async def check_all_watchlist_items(
current_user: User = Depends(get_current_user_from_token),
):
"""Manually trigger a check for all due watchlist items"""
from main import episode_checker, watchlist_manager
try:
results = await episode_checker.check_all_due()
user_results = []
for result in results:
item = watchlist_manager.get_by_id(result.watchlist_item_id)
if item and item.user_id == current_user.id:
user_results.append(result)
return {
"status": "success",
"checked": len(user_results),
"total_new_episodes": sum(r.new_episodes_found for r in user_results),
"total_downloaded": sum(len(r.episodes_downloaded) for r in user_results),
"results": user_results,
}
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error checking all watchlist items: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/scheduler/status")
async def get_scheduler_status(
current_user: User = Depends(get_current_user_from_token),
):
"""Get auto-download scheduler status"""
from main import auto_download_scheduler, watchlist_manager
try:
return {
"running": auto_download_scheduler.is_running(),
"next_run": auto_download_scheduler.get_next_run_time(),
"settings": watchlist_manager.get_settings(),
}
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error getting scheduler status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/scheduler/start")
async def start_scheduler(
current_user: User = Depends(get_current_user_from_token),
):
"""Start the auto-download scheduler"""
from main import auto_download_scheduler
try:
if auto_download_scheduler.is_running():
return {
"status": "already_running",
"message": "Scheduler is already running",
}
auto_download_scheduler.start()
return {"status": "started", "message": "Scheduler started successfully"}
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error starting scheduler: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/scheduler/stop")
async def stop_scheduler(
current_user: User = Depends(get_current_user_from_token),
):
"""Stop the auto-download scheduler"""
from main import auto_download_scheduler
try:
if not auto_download_scheduler.is_running():
return {"status": "not_running", "message": "Scheduler is not running"}
auto_download_scheduler.stop()
return {"status": "stopped", "message": "Scheduler stopped successfully"}
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error stopping scheduler: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@@ -227,233 +112,73 @@ async def get_watchlist_item(
):
"""Get a specific watchlist item"""
from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Item not found")
return item
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error getting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{item_id}", response_model=WatchlistItem)
async def update_watchlist_item(
item_id: str,
update_data: WatchlistItemUpdate,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Update a watchlist item"""
from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
from logging import getLogger
if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Item not found")
logger = getLogger(__name__)
logger.error(f"Error updating watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
updated_item = watchlist_manager.update(item_id, update_data)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{updated_item.anime_title}' mis à jour", "type": "success"}})
return updated_item
@router.delete("/{item_id}")
async def delete_watchlist_item(
async def delete_from_watchlist(
item_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Delete an anime from the watchlist"""
"""Remove an anime from the watchlist"""
from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
success = watchlist_manager.delete(item_id)
if not success:
if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Item not found")
title = item.anime_title
if watchlist_manager.delete(item_id):
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{title}' supprimé de la watchlist", "type": "info"}})
# HTMX will handle removing the element if target is specified in the frontend
return Response(status_code=204)
raise HTTPException(status_code=500, detail="Failed to delete item")
return {"status": "success", "message": "Item deleted from watchlist"}
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error deleting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{item_id}/check")
async def check_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token),
):
"""Manually trigger a check for new episodes"""
from main import episode_checker, watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
result = await episode_checker.manual_check(item_id)
if not result:
raise HTTPException(status_code=500, detail="Check failed")
return result
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error checking watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{item_id}/download-all")
async def download_all_episodes(
item_id: str,
@router.post("/check", response_model=List)
async def check_watchlist_now(
background_tasks: BackgroundTasks,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Download the LATEST SEASON episodes for a watchlist item"""
from main import download_manager, watchlist_manager
"""Trigger an immediate check for new episodes"""
from main import auto_download_scheduler
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}})
downloader = get_downloader(item.anime_url)
latest_season_url = item.anime_url
if hasattr(downloader, "get_seasons"):
try:
seasons = await downloader.get_seasons(item.anime_url)
if seasons and len(seasons) > 0:
latest_season = seasons[-1]
latest_season_url = latest_season.get("url", item.anime_url)
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.warning(f"Could not fetch seasons, using default URL: {e}")
episodes = await downloader.get_episodes(latest_season_url, item.lang)
if not episodes:
return {
"status": "warning",
"message": f"No episodes found for {item.anime_title}",
"result": {"new_episodes_found": 0, "episodes_downloaded": []},
}
task_ids = []
season_match = re.search(r"saison(\d+)", latest_season_url, re.IGNORECASE)
season_num = season_match.group(1) if season_match else "1"
anime_title_clean = (
item.anime_title.replace("/", "-").replace("\\", "-").strip()
)
for episode in episodes:
ep_num = episode.get("episode", "01")
filename = f"{anime_title_clean} - S{season_num} - Episode {ep_num}.mp4"
request = DownloadRequest(url=episode["url"], filename=filename)
task = download_manager.create_task(request)
task_ids.append(task.id)
background_tasks.add_task(download_manager.start_download, task.id)
watchlist_manager.update(
item_id,
{"last_episode_downloaded": len(episodes), "total_episodes": len(episodes)},
)
return {
"status": "success",
"message": f"Downloading {len(task_ids)} episodes from latest season for {item.anime_title}",
"task_ids": task_ids,
"total_episodes": len(episodes),
"season_url": latest_season_url,
}
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error downloading all episodes: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return {"status": "success", "message": "Check triggered"}
@router.post("/{item_id}/pause", response_model=WatchlistItem)
async def pause_watchlist_item(
item_id: str,
@router.get("/stats/summary")
async def get_watchlist_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Pause automatic downloading for a specific anime"""
"""Get watchlist statistics for the user"""
from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.PAUSED)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error pausing watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{item_id}/resume", response_model=WatchlistItem)
async def resume_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token),
):
"""Resume automatic downloading for a paused anime"""
from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.ACTIVE)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error resuming watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return watchlist_manager.get_stats(current_user.id)
-1
View File
@@ -133,7 +133,6 @@ from app.routers import (
app.include_router(root_router)
app.include_router(auth_router)
app.include_router(downloads_router)
app.include_router(downloads_legacy_router)
app.include_router(anime_router)
app.include_router(favorites_router)
app.include_router(recommendations_router)
+6 -41
View File
@@ -91,8 +91,12 @@ async function checkAuth() {
if (response.ok) {
const data = await response.json();
showUserInfo(data.user);
showMainContent();
// Dispatch event for Alpine.js global state
window.dispatchEvent(new CustomEvent('auth-success', {
detail: { username: data.user.full_name || data.user.username }
}));
return true;
} else {
// Token invalid, remove it and redirect
@@ -116,45 +120,6 @@ function redirectToLogin() {
}
}
// Show user info when authenticated
function showUserInfo(user) {
const userInfo = document.getElementById('userInfo');
const loginPrompt = document.getElementById('loginPrompt');
const mainTabs = document.getElementById('mainTabs');
const currentUser = document.getElementById('currentUser');
if (userInfo) userInfo.style.display = 'flex';
if (loginPrompt) loginPrompt.style.display = 'none';
if (mainTabs) mainTabs.style.visibility = 'visible';
if (currentUser) currentUser.textContent = user.full_name || user.username;
}
// Show main content (only when authenticated)
function showMainContent() {
const mainContent = document.getElementById('main-content');
if (mainContent) mainContent.style.display = 'block';
}
// Hide main content (when not authenticated)
function hideMainContent() {
const mainContent = document.getElementById('main-content');
if (mainContent) mainContent.style.display = 'none';
}
// Show login prompt when not authenticated (not used anymore - we redirect instead)
function showLoginPrompt() {
const userInfo = document.getElementById('userInfo');
const loginPrompt = document.getElementById('loginPrompt');
const mainTabs = document.getElementById('mainTabs');
if (userInfo) userInfo.style.display = 'none';
if (loginPrompt) loginPrompt.style.display = 'block';
if (mainTabs) mainTabs.style.visibility = 'hidden';
// Hide main content
hideMainContent();
}
// Handle logout
async function handleLogout() {
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) {
+14 -2
View File
@@ -7,8 +7,14 @@
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<!-- JavaScript -->
<!-- External Libraries -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<!-- Legacy JavaScript (To be refactored) -->
<script src="/static/js/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script>
<script src="/static/js/utils.js?v=1.11" defer></script>
@@ -22,7 +28,13 @@
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
<script src="/static/js/main.js?v=1.11" defer></script>
</head>
<body>
<body x-data="{
activeTab: 'home',
isAuthenticated: false,
username: ''
}" @set-tab.window="activeTab = $event.detail.tab"
@auth-success.window="isAuthenticated = true; username = $event.detail.username; activeTab = 'home'">
{% include "components/toast_container.html" %}
<div class="container">
{% block content %}{% endblock %}
</div>
+43
View File
@@ -0,0 +1,43 @@
{% macro anime_card(anime, in_watchlist=False) %}
<div class="anime-card" id="anime-{{ anime.url | hash }}">
<div class="anime-poster">
<img src="{{ anime.cover_image or anime.metadata.poster_image or '/static/img/no-poster.png' }}"
alt="{{ anime.title }}"
loading="lazy">
<div class="anime-overlay">
<button class="btn-play"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-play"></i>
</button>
</div>
{% if anime.metadata and anime.metadata.rating %}
<div class="anime-rating">{{ anime.metadata.rating }}</div>
{% endif %}
</div>
<div class="anime-info">
<h3 class="anime-title" title="{{ anime.title }}">{{ anime.title }}</h3>
<div class="anime-meta">
<span class="badge badge-provider">{{ anime.provider_id or 'unknown' }}</span>
{% if anime.metadata and anime.metadata.status %}
<span class="badge badge-status">{{ anime.metadata.status }}</span>
{% endif %}
</div>
<div class="anime-actions">
{% if not in_watchlist %}
<button class="btn btn-sm btn-outline"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}"}'
hx-swap="none"
hx-on::after-request="this.remove()">
<i class="fas fa-plus"></i> Watchlist
</button>
{% else %}
<span class="text-muted small"><i class="fas fa-check"></i> Dans la watchlist</span>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,42 @@
{% from "components/anime_card.html" import anime_card %}
<div class="search-results-container">
{% if results %}
{% for provider_id, items in results.items() %}
<div class="provider-section">
<h3 class="provider-title">{{ provider_id | upper }}</h3>
<div class="anime-grid">
{% for anime in items %}
{{ anime_card(anime) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-results">
<i class="fas fa-search"></i>
<p>Aucun résultat trouvé pour votre recherche.</p>
</div>
{% endif %}
</div>
<style>
.provider-section { margin-bottom: 30px; }
.provider-title {
border-bottom: 2px solid #00d9ff;
padding-bottom: 5px;
margin-bottom: 15px;
font-size: 1.2rem;
}
.anime-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
.no-results {
text-align: center;
padding: 50px;
color: #aaa;
}
.no-results i { font-size: 3rem; margin-bottom: 10px; display: block; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<div class="downloads-grid"
hx-get="/api/downloads?html=true"
hx-trigger="every 2s"
hx-swap="innerHTML">
{% if tasks %}
{% for task_id, task in tasks.items() %}
<div class="download-item task-{{ task.status }}">
<div class="download-info">
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
<span class="badge badge-{{ task.status }}">{{ task.status }}</span>
</div>
<div class="progress-container">
<div class="progress-bar" style="width: {{ task.progress }}%"></div>
</div>
<div class="download-meta">
<span>{{ task.progress | round(1) }}%</span>
<span>{{ task.download_speed }}</span>
<span>{{ task.eta }}</span>
</div>
<div class="download-actions">
{% if task.status == 'downloading' or task.status == 'pending' %}
<button class="btn-icon" hx-post="/api/downloads/{{ task_id }}/pause" hx-swap="none">
<i class="fas fa-pause"></i>
</button>
{% elif task.status == 'paused' %}
<button class="btn-icon" hx-post="/api/downloads/{{ task_id }}/resume" hx-swap="none">
<i class="fas fa-play"></i>
</button>
{% endif %}
{% if task.status == 'completed' %}
<a href="/video/{{ task_id }}" class="btn-icon success">
<i class="fas fa-external-link-alt"></i>
</a>
{% endif %}
<button class="btn-icon danger"
hx-delete="/api/downloads/{{ task_id }}"
hx-confirm="Supprimer ce téléchargement ?"
hx-swap="none">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Aucun téléchargement en cours</p>
</div>
{% endif %}
</div>
+74 -56
View File
@@ -1,63 +1,81 @@
<!-- Downloads Section with Filters -->
<div class="section-container">
<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 annulés, échoués et 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
<h2>📥 Téléchargements</h2>
<div class="header-actions">
<button class="btn btn-sm btn-secondary"
hx-post="/api/downloads/cleanup"
hx-swap="none"
title="Supprimer les téléchargements terminés de la liste">
Nettoyer terminés
</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 id="downloads-container">
{% include "components/downloads_list.html" %}
</div>
</div>
<style>
.section-container { margin-bottom: 40px; }
.download-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.progress-container {
height: 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
margin: 10px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #00d9ff, #00ff88);
transition: width 0.3s ease;
}
.download-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.download-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
}
.download-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #aaa;
}
.download-actions {
display: flex;
gap: 10px;
margin-top: 10px;
justify-content: flex-end;
}
.btn-icon {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.btn-icon:hover { background: rgba(0, 217, 255, 0.2); color: #00d9ff; }
.btn-icon.danger:hover { background: rgba(244, 67, 54, 0.2); color: #f44336; }
.btn-icon.success:hover { background: rgba(76, 175, 80, 0.2); color: #4caf50; }
</style>
+31 -17
View File
@@ -2,53 +2,67 @@
<p class="subtitle">Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés</p>
<!-- User info and logout button -->
<div id="userInfo" style="display: none; margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; justify-content: space-between; align-items: center;">
<div id="userInfo"
x-show="isAuthenticated"
x-cloak
style="margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: #00d9ff;">👤</span>
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong id="currentUser" style="color: #00d9ff;">-</strong></span>
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: #00d9ff;">-</strong></span>
</div>
<button class="btn-secondary btn-small" onclick="handleLogout()" style="padding: 5px 15px; font-size: 12px;">
<button class="btn-secondary btn-small"
hx-post="/api/auth/logout"
hx-on::after-request="isAuthenticated = false; window.location.reload()"
style="padding: 5px 15px; font-size: 12px;">
🚪 Déconnexion
</button>
</div>
<!-- Login prompt (shown when not logged in) -->
<div id="loginPrompt" style="display: none; margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;">
<div id="loginPrompt"
x-show="!isAuthenticated"
x-cloak
style="margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;">
<p style="color: #00d9ff; margin: 0 0 10px 0;">👋 Bienvenue! <a href="/login" style="color: #00d9ff; text-decoration: underline;">Connectez-vous</a> pour télécharger des vidéos</p>
</div>
<!-- Tabs - Hidden by default, shown only when authenticated -->
<div id="mainTabs" class="tabs" style="visibility: hidden;">
<button class="tab active" data-tab-type="home" onclick="switchTab('home')">
<!-- Tabs - Shown only when authenticated -->
<div id="mainTabs" class="tabs" x-show="isAuthenticated" x-cloak style="visibility: visible;">
<button class="tab" :class="{ 'active': activeTab === 'home' }" @click="activeTab = 'home'">
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Accueil
</button>
<button class="tab" data-tab-type="anime" onclick="switchTab('anime')">
<button class="tab" :class="{ 'active': activeTab === 'anime' }" @click="activeTab = 'anime'">
<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="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>
Anime
</button>
<button class="tab" data-tab-type="series" onclick="switchTab('series')">
<button class="tab" :class="{ 'active': activeTab === 'series' }" @click="activeTab = 'series'">
<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="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
</svg>
Série
</button>
<button class="tab" data-tab-type="providers" onclick="switchTab('providers')">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
Fournisseurs
</button>
<button class="tab" data-tab-type="watchlist" onclick="switchTab('watchlist')">
<button class="tab" :class="{ 'active': activeTab === 'watchlist' }" @click="activeTab = 'watchlist'">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
</svg>
Watchlist
</button>
<!-- Provider tabs will be loaded dynamically after the static tabs -->
<button class="tab" :class="{ 'active': activeTab === 'downloads' }" @click="activeTab = 'downloads'">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Téléchargements
</button>
<button class="tab" :class="{ 'active': activeTab === 'providers' }" @click="activeTab = 'providers'">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
Fournisseurs
</button>
</div>
+54
View File
@@ -0,0 +1,54 @@
<div id="toast-container"
class="toast-container"
x-data="{ toasts: [] }"
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
<template x-for="toast in toasts" :key="toast.id">
<div class="toast"
:class="'toast-' + toast.type"
x-show="true"
x-transition:enter="toast-enter"
x-transition:leave="toast-leave">
<div class="toast-content">
<i class="fas" :class="{
'fa-check-circle': toast.type === 'success',
'fa-exclamation-circle': toast.type === 'error',
'fa-info-circle': toast.type === 'info'
}"></i>
<span x-text="toast.message"></span>
</div>
<button class="toast-close" @click="toasts = toasts.filter(t => t.id !== toast.id)">
<i class="fas fa-times"></i>
</button>
</div>
</template>
</div>
<style>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
min-width: 250px;
padding: 12px 16px;
border-radius: 8px;
background: #2d2d2d;
color: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid #ccc;
}
.toast-success { border-left-color: #4caf50; }
.toast-error { border-left-color: #f44336; }
.toast-info { border-left-color: #2196f3; }
.toast-content { display: flex; align-items: center; gap: 10px; }
.toast-close { background: none; border: none; color: #aaa; cursor: pointer; }
</style>
@@ -0,0 +1,39 @@
{% if items %}
<div class="watchlist-grid">
{% for item in items %}
<div class="watchlist-item card" id="watchlist-{{ item.id }}">
<div class="item-poster">
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}">
</div>
<div class="item-info">
<h3>{{ item.anime_title }}</h3>
<div class="item-meta">
<span class="badge">{{ item.provider_id }}</span>
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
</div>
<div class="item-stats">
<span>Épisode: {{ item.last_episode_downloaded }}</span>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-primary"
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}"
hx-target="#player-container">
<i class="fas fa-play"></i>
</button>
<button class="btn btn-sm btn-danger"
hx-delete="/api/watchlist/{{ item.id }}"
hx-target="#watchlist-{{ item.id }}"
hx-swap="outerHTML"
hx-confirm="Retirer de la watchlist ?">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Votre watchlist est vide.</p>
</div>
{% endif %}
+44 -32
View File
@@ -1,37 +1,49 @@
<!-- Watchlist Section: Scheduler, Filters & Items -->
<!-- Scheduler Status -->
<div class="scheduler-status" id="schedulerStatus">
<div class="scheduler-status-header">
<div>
<h3>⏰ Planificateur Automatique</h3>
<div id="nextRunInfo" class="next-run-info">Chargement...</div>
<div class="section-container">
<div class="section-header">
<h2>📋 Ma Watchlist</h2>
<div class="header-actions">
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
<i class="fas fa-sync"></i> Vérifier épisodes
</button>
<button class="btn btn-sm btn-secondary"
hx-get="/api/watchlist"
hx-target="#watchlist-items-container">
<i class="fas fa-redo"></i> Actualiser
</button>
</div>
<div class="scheduler-controls">
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
▶️ Démarrer
</button>
<button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;">
⏸️ Arrêter
</button>
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()">
🔍 Vérifier tout
</button>
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
⚙️ Paramètres
</button>
</div>
<!-- Watchlist items container loaded via HTMX on page load or manual refresh -->
<div id="watchlist-items-container"
hx-get="/api/watchlist"
hx-trigger="load"
class="watchlist-content">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement de votre watchlist...
</div>
</div>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
<button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
<button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
<button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
</div>
<!-- Watchlist Items -->
<div id="watchlistContainer">
<div class="watchlist-loading">Chargement de la watchlist...</div>
</div>
<style>
.watchlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.watchlist-item {
display: flex;
gap: 15px;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.2s;
}
.watchlist-item:hover { transform: translateY(-3px); border-color: #00d9ff; }
.item-poster img { width: 80px; height: 120px; border-radius: 8px; object-fit: cover; }
.item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #fff; }
.item-meta { display: flex; gap: 8px; margin-bottom: 8px; }
.item-actions { display: flex; gap: 10px; margin-top: 10px; }
</style>
+19 -8
View File
@@ -3,30 +3,38 @@
{% block content %}
{% include "components/header.html" %}
<!-- Main content - Hidden by default, shown only when authenticated -->
<div id="main-content" style="display: none;">
<!-- Main content - Shown only when authenticated -->
<div id="main-content" x-show="isAuthenticated" x-cloak>
{% include "components/home_section.html" %}
<!-- Nouveaux onglets -->
<div id="tab-anime" class="tab-content">
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
<!-- Anime Search Section -->
<div class="section-header">
<h2>🎬 Rechercher un Anime</h2>
</div>
<div class="url-form">
<div class="input-group">
<form hx-get="/api/anime/search"
hx-target="#animeSearchResults"
hx-indicator="#search-loading"
class="input-group">
<input
type="text"
name="q"
id="animeSearchInput"
placeholder="Rechercher un anime (ex: Frieren, One Piece, Naruto...)"
required
>
<button type="button" class="btn-primary" onclick="handleAnimeSearch()">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Rechercher
</button>
</form>
<div id="search-loading" class="htmx-indicator">
<div class="spinner"></div> Recherche en cours...
</div>
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;">
💡 <strong>Info:</strong> La recherche utilise MyAnimeList pour afficher la fiche complète (synopsis, saisons, etc.)
@@ -51,7 +59,7 @@
<div id="animeReleasesList" class="recommendations-carousel"></div>
</div>
<div id="tab-series" class="tab-content">
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
<!-- Series Search Section -->
<div class="section-header">
<h2>📺 Rechercher une Série TV</h2>
@@ -105,18 +113,21 @@
<div id="seriesReleasesList" class="releases-carousel"></div>
</div>
<div id="tab-providers" class="tab-content">
<div id="tab-providers" class="tab-content" x-show="activeTab === 'providers'">
<div class="section-header">
<h2>📦 Fournisseurs de Streaming</h2>
<button class="btn btn-sm btn-secondary" hx-get="/api/providers/health" hx-target="#providersGrid">Actualiser</button>
</div>
<div id="providersGrid" class="search-results"></div>
</div>
<div id="tab-watchlist" class="tab-content">
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
{% include "components/watchlist_section.html" %}
</div>
<div id="tab-downloads" class="tab-content" x-show="activeTab === 'downloads'">
{% include "components/downloads_section.html" %}
</div>
</div>
<!-- End of main-content -->
+23 -64
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ filename }} - Ohm Stream Player</title>
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<style>
* {
margin: 0;
@@ -67,10 +68,8 @@
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
video {
width: 100%;
display: block;
max-height: 80vh;
.plyr {
border-radius: 15px;
}
.controls {
@@ -123,41 +122,13 @@
margin-top: 20px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.loading::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.2rem;
}
.video-info {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
.video-info { flex-direction: column; align-items: flex-start; }
.controls { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
}
</style>
</head>
@@ -173,12 +144,8 @@
</div>
<div class="video-wrapper">
<video controls preload="metadata">
<video id="player" playsinline controls preload="metadata">
<source src="/stream/{{ filename }}" type="video/mp4">
<div class="error-message">
Votre navigateur ne supporte pas la lecture vidéo.<br>
<a href="/stream/{{ filename }}" style="color: #00d9ff;">Télécharger la vidéo</a>
</div>
</video>
</div>
@@ -188,32 +155,24 @@
</div>
</div>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<script>
// Video error handling
const video = document.querySelector('video');
video.addEventListener('error', (e) => {
console.error('Video error:', e);
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.innerHTML = `
Erreur lors du chargement de la vidéo.<br>
<a href="/video/{{ task_id }}" style="color: #00d9ff;">Réessayer</a>
const player = new Plyr('#player', {
captions: { active: true, update: true, language: 'auto' },
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }
});
// Error handling
player.on('error', (error) => {
console.error('Plyr error:', error);
const wrapper = document.querySelector('.video-wrapper');
wrapper.innerHTML = `
<div class="error-message">
Erreur lors de la lecture du flux vidéo.<br>
<a href="/video/{{ task_id }}" style="color: #00d9ff; text-decoration: underline;">Réessayer</a> ou
<a href="/stream/{{ filename }}" style="color: #00d9ff; text-decoration: underline;" download>Télécharger</a>
</div>
`;
video.parentNode.replaceChild(errorDiv, video);
});
// Video loaded successfully
video.addEventListener('loadedmetadata', () => {
console.log('Video duration:', video.duration);
});
// Log seeking events for debugging
video.addEventListener('seeking', () => {
console.log('Seeking to:', video.currentTime);
});
video.addEventListener('seeked', () => {
console.log('Seeked to:', video.currentTime);
});
</script>
</body>