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_auth import router as auth_router
from app.routers.router_downloads import ( from app.routers.router_downloads import router as downloads_router
router as downloads_router,
legacy_router as downloads_legacy_router,
)
from app.routers.router_anime import router as anime_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_favorites import router as favorites_router
from app.routers.router_recommendations import router as recommendations_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__ = [ __all__ = [
"auth_router", "auth_router",
"downloads_router", "downloads_router",
"downloads_legacy_router",
"anime_router", "anime_router",
"favorites_router", "favorites_router",
"recommendations_router", "recommendations_router",
+19 -21
View File
@@ -1,20 +1,5 @@
""" """
Anime and series search routes for Ohm Stream Downloader API. 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 import json
@@ -22,8 +7,10 @@ import re
import time import time
import logging import logging
import asyncio 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.download_manager import DownloadManager
from app.downloaders import ( from app.downloaders import (
@@ -40,6 +27,13 @@ from app.metadata_enrichment import get_metadata_enricher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["anime"]) 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") @router.get("/providers/health")
@@ -67,6 +61,7 @@ def get_download_manager() -> DownloadManager:
@router.get("/anime/search") @router.get("/anime/search")
async def search_anime_unified( async def search_anime_unified(
request: Request,
q: str, q: str,
lang: str = "vostfr", lang: str = "vostfr",
include_metadata: bool = False, include_metadata: bool = False,
@@ -74,6 +69,7 @@ async def search_anime_unified(
""" """
Search across all anime providers using MetadataEnricher and health checks. Search across all anime providers using MetadataEnricher and health checks.
Results are grouped by provider for legacy UI compatibility. 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}") print(f"\n[SEARCH] Starting modern unified search for '{q}' in {lang}")
start_time = time.time() start_time = time.time()
@@ -87,7 +83,6 @@ async def search_anime_unified(
# Generic YAML providers # Generic YAML providers
active_generic = providers_manager.get_active_providers() active_generic = providers_manager.get_active_providers()
for provider in active_generic: for provider in active_generic:
print(f"[SEARCH] Queueing generic provider: {provider.name}")
search_tasks.append(provider.search(q)) search_tasks.append(provider.search(q))
task_metadata.append({"id": provider.id, "type": "generic"}) task_metadata.append({"id": provider.id, "type": "generic"})
@@ -98,20 +93,16 @@ async def search_anime_unified(
"vostfree": VostfreeDownloader(), "vostfree": VostfreeDownloader(),
} }
for pid, dl in legacy_downloaders.items(): 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)) search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
task_metadata.append({"id": pid, "type": "legacy"}) task_metadata.append({"id": pid, "type": "legacy"})
# 2. Run searches in parallel # 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) all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# 3. Organize results by provider # 3. Organize results by provider
seen_urls = set() seen_urls = set()
enricher = await get_metadata_enricher() enricher = await get_metadata_enricher()
enrichment_tasks = [] enrichment_tasks = []
# Map task indices to result slots for re-injection after enrichment
enrichment_mapping = [] # List of (provider_id, index_in_provider_results) enrichment_mapping = [] # List of (provider_id, index_in_provider_results)
for i, raw_result in enumerate(all_raw_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()) 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.") 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 { return {
"query": q, "query": q,
"lang": lang, "lang": lang,
+50 -97
View File
@@ -2,56 +2,48 @@
Download management routes for Ohm Stream Downloader API. Download management routes for Ohm Stream Downloader API.
""" """
import os from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest, DownloadStatus from app.models import DownloadRequest
from app.utils import is_safe_filename, sanitize_filename 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: def get_download_manager() -> DownloadManager:
from main import download_manager from main import download_manager
return 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("") @router.post("")
async def create_download( async def create_download(
request: DownloadRequest, download_request: DownloadRequest,
background_tasks: BackgroundTasks,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
): ):
"""Create a new download task""" """Create a new download task"""
if request.filename: return download_manager.create_task(download_request)
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}
@router.get("/{task_id}") @router.get("/{task_id}")
@@ -59,7 +51,7 @@ async def get_download_status(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), 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) task = download_manager.get_task(task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
@@ -70,82 +62,43 @@ async def get_download_status(
async def pause_download( async def pause_download(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
): ):
"""Pause a download""" """Pause a download task"""
task = download_manager.get_task(task_id) if download_manager.pause_download(task_id):
if not task: return {"status": "success", "message": "Download paused"}
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=400, detail="Failed to pause download")
await download_manager.pause_download(task_id)
return {"status": "paused"}
@router.post("/{task_id}/resume") @router.post("/{task_id}/resume")
async def resume_download( async def resume_download(
task_id: str, task_id: str,
background_tasks: BackgroundTasks,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
): ):
"""Resume a paused download""" """Resume a paused download task"""
task = download_manager.get_task(task_id) if download_manager.resume_download(task_id):
if not task: return {"status": "success", "message": "Download resumed"}
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=400, detail="Failed to resume download")
if task.status == DownloadStatus.PAUSED:
background_tasks.add_task(download_manager.start_download, task_id)
return {"status": "resumed"}
return {"status": "already running or completed"}
@router.delete("/{task_id}") @router.delete("/{task_id}")
async def delete_download( async def cancel_download(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
): ):
"""Delete/cancel a download""" """Cancel and delete a download task"""
task = download_manager.get_task(task_id) if download_manager.cancel_download(task_id):
if not task: return {"status": "success", "message": "Download cancelled"}
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=400, detail="Failed to cancel download")
await download_manager.delete_task(task_id)
return {"status": "deleted"}
@router.get("/{task_id}/file") @router.post("/cleanup")
async def download_file( async def cleanup_completed(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user=Depends(get_current_user_from_token),
): ):
"""Download the completed file""" """Remove all completed tasks from the list"""
task = download_manager.get_task(task_id) count = download_manager.cleanup_tasks()
if not task: return {"status": "success", "message": f"Cleaned up {count} tasks"}
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()}
+60 -335
View File
@@ -3,9 +3,11 @@ Watchlist management routes for Ohm Stream Downloader API.
""" """
import re import re
import json
from typing import List, Optional 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.download_manager import DownloadManager
from app.downloaders import get_downloader 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 from app.routers.router_auth import get_current_user_from_token
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"]) router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
templates = Jinja2Templates(directory="templates")
def get_download_manager() -> DownloadManager: def get_download_manager() -> DownloadManager:
from main import download_manager from main import download_manager
return download_manager return download_manager
@router.post("", response_model=WatchlistItem) @router.post("", response_model=WatchlistItem)
async def add_to_watchlist( async def add_to_watchlist(
item_data: WatchlistItemCreate, item_data: WatchlistItemCreate,
response: Response,
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Add an anime to the watchlist""" """Add an anime to the watchlist"""
from main import watchlist_manager from main import watchlist_manager
try: 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 return item
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
logger.error(f"Error adding to watchlist: {e}", exc_info=True) logger.error(f"Error adding to watchlist: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -52,21 +58,22 @@ async def add_to_watchlist(
@router.get("", response_model=List[WatchlistItem]) @router.get("", response_model=List[WatchlistItem])
async def get_watchlist( async def get_watchlist(
request: Request,
status: Optional[WatchlistStatus] = None, status: Optional[WatchlistStatus] = None,
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token), 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 from main import watchlist_manager
try:
items = watchlist_manager.get_all(user_id=current_user.id, status=status) 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__) if html or request.headers.get("HX-Request"):
logger.error(f"Error getting watchlist: {e}", exc_info=True) return templates.TemplateResponse(
raise HTTPException(status_code=500, detail=str(e)) "components/watchlist_items_list.html",
{"request": request, "items": items}
)
return items
@router.get("/settings", response_model=WatchlistSettings) @router.get("/settings", response_model=WatchlistSettings)
@@ -75,21 +82,13 @@ async def get_watchlist_settings(
): ):
"""Get global watchlist settings""" """Get global watchlist settings"""
from main import watchlist_manager from main import watchlist_manager
return watchlist_manager.get_settings()
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))
@router.put("/settings", response_model=WatchlistSettings) @router.put("/settings", response_model=WatchlistSettings)
async def update_watchlist_settings( async def update_watchlist_settings(
settings: WatchlistSettings, settings: WatchlistSettings,
response: Response,
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Update global watchlist settings""" """Update global watchlist settings"""
@@ -98,125 +97,11 @@ async def update_watchlist_settings(
try: try:
updated_settings = watchlist_manager.update_settings(settings) updated_settings = watchlist_manager.update_settings(settings)
if auto_download_scheduler.is_running(): 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 return updated_settings
except Exception as e: 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)) raise HTTPException(status_code=500, detail=str(e))
@@ -227,233 +112,73 @@ async def get_watchlist_item(
): ):
"""Get a specific watchlist item""" """Get a specific watchlist item"""
from main import watchlist_manager from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item: if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Watchlist item not found") raise HTTPException(status_code=404, detail="Item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return item 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) @router.put("/{item_id}", response_model=WatchlistItem)
async def update_watchlist_item( async def update_watchlist_item(
item_id: str, item_id: str,
update_data: WatchlistItemUpdate, update_data: WatchlistItemUpdate,
response: Response,
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Update a watchlist item""" """Update a watchlist item"""
from main import watchlist_manager from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item: if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Watchlist item not found") raise HTTPException(status_code=404, detail="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
logger = getLogger(__name__) updated_item = watchlist_manager.update(item_id, update_data)
logger.error(f"Error updating watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) 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}") @router.delete("/{item_id}")
async def delete_watchlist_item( async def delete_from_watchlist(
item_id: str, item_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token), 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 from main import watchlist_manager
try:
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item: if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Watchlist item not found") raise HTTPException(status_code=404, detail="Item not found")
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") title = item.anime_title
success = watchlist_manager.delete(item_id) if watchlist_manager.delete(item_id):
if not success: 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") 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") @router.post("/check", response_model=List)
async def check_watchlist_item( async def check_watchlist_now(
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,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
response: Response,
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Download the LATEST SEASON episodes for a watchlist item""" """Trigger an immediate check for new episodes"""
from main import download_manager, watchlist_manager from main import auto_download_scheduler
try: background_tasks.add_task(auto_download_scheduler.trigger_check_now)
item = watchlist_manager.get_by_id(item_id) response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}})
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")
downloader = get_downloader(item.anime_url) return {"status": "success", "message": "Check triggered"}
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))
@router.post("/{item_id}/pause", response_model=WatchlistItem) @router.get("/stats/summary")
async def pause_watchlist_item( async def get_watchlist_stats(
item_id: str,
current_user: User = Depends(get_current_user_from_token), 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 from main import watchlist_manager
return watchlist_manager.get_stats(current_user.id)
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))
-1
View File
@@ -133,7 +133,6 @@ from app.routers import (
app.include_router(root_router) app.include_router(root_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(downloads_router) app.include_router(downloads_router)
app.include_router(downloads_legacy_router)
app.include_router(anime_router) app.include_router(anime_router)
app.include_router(favorites_router) app.include_router(favorites_router)
app.include_router(recommendations_router) app.include_router(recommendations_router)
+6 -41
View File
@@ -91,8 +91,12 @@ async function checkAuth() {
if (response.ok) { if (response.ok) {
const data = await response.json(); 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; return true;
} else { } else {
// Token invalid, remove it and redirect // 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 // Handle logout
async function handleLogout() { async function handleLogout() {
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) { if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) {
+14 -2
View File
@@ -7,8 +7,14 @@
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="/static/css/style.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/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script> <script src="/static/js/api.js?v=1.11" defer></script>
<script src="/static/js/utils.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/watchlist-ui.js?v=1.11" defer></script>
<script src="/static/js/main.js?v=1.11" defer></script> <script src="/static/js/main.js?v=1.11" defer></script>
</head> </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"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </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"> <div class="section-header">
<h2>Téléchargements</h2> <h2>📥 Téléchargements</h2>
<div class="downloads-stats" id="downloadsStats"></div> <div class="header-actions">
</div> <button class="btn btn-sm btn-secondary"
hx-post="/api/downloads/cleanup"
<!-- Filters and Controls --> hx-swap="none"
<div class="downloads-controls"> title="Supprimer les téléchargements terminés de la liste">
<div class="filter-group"> Nettoyer terminés
<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
</button> </button>
</div> </div>
</div> </div>
<div id="downloadsList" class="downloads-list"> <div id="downloads-container">
<div class="empty-state"> {% include "components/downloads_list.html" %}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<p>Aucun téléchargement pour le moment</p>
</div> </div>
</div> </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> <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 --> <!-- 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="color: #00d9ff;">👤</span> <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> </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 🚪 Déconnexion
</button> </button>
</div> </div>
<!-- Login prompt (shown when not logged in) --> <!-- 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> <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> </div>
<!-- Tabs - Hidden by default, shown only when authenticated --> <!-- Tabs - Shown only when authenticated -->
<div id="mainTabs" class="tabs" style="visibility: hidden;"> <div id="mainTabs" class="tabs" x-show="isAuthenticated" x-cloak style="visibility: visible;">
<button class="tab active" data-tab-type="home" onclick="switchTab('home')"> <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"> <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> <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> </svg>
Accueil Accueil
</button> </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"> <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="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> <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> </svg>
Anime Anime
</button> </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"> <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> <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> </svg>
Série Série
</button> </button>
<button class="tab" data-tab-type="providers" onclick="switchTab('providers')"> <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="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')">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> <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> </svg>
Watchlist Watchlist
</button> </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> </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 --> <div class="section-container">
<!-- Scheduler Status --> <div class="section-header">
<div class="scheduler-status" id="schedulerStatus"> <h2>📋 Ma Watchlist</h2>
<div class="scheduler-status-header"> <div class="header-actions">
<div> <button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
<h3>⏰ Planificateur Automatique</h3> <i class="fas fa-sync"></i> Vérifier épisodes
<div id="nextRunInfo" class="next-run-info">Chargement...</div> </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>
<div class="scheduler-controls"> </div>
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
▶️ Démarrer <!-- Watchlist items container loaded via HTMX on page load or manual refresh -->
</button> <div id="watchlist-items-container"
<button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;"> hx-get="/api/watchlist"
⏸️ Arrêter hx-trigger="load"
</button> class="watchlist-content">
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()"> <div class="loading-placeholder">
🔍 Vérifier tout <div class="spinner"></div> Chargement de votre watchlist...
</button>
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
⚙️ Paramètres
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Filter Tabs --> <style>
<div class="filter-tabs"> .watchlist-grid {
<button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button> display: grid;
<button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button> grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
<button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button> gap: 20px;
<button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button> margin-top: 20px;
</div> }
.watchlist-item {
<!-- Watchlist Items --> display: flex;
<div id="watchlistContainer"> gap: 15px;
<div class="watchlist-loading">Chargement de la watchlist...</div> padding: 15px;
</div> 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 %} {% block content %}
{% include "components/header.html" %} {% include "components/header.html" %}
<!-- Main content - Hidden by default, shown only when authenticated --> <!-- Main content - Shown only when authenticated -->
<div id="main-content" style="display: none;"> <div id="main-content" x-show="isAuthenticated" x-cloak>
{% include "components/home_section.html" %} {% include "components/home_section.html" %}
<!-- Nouveaux onglets --> <!-- Nouveaux onglets -->
<div id="tab-anime" class="tab-content"> <div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
<!-- Anime Search Section --> <!-- Anime Search Section -->
<div class="section-header"> <div class="section-header">
<h2>🎬 Rechercher un Anime</h2> <h2>🎬 Rechercher un Anime</h2>
</div> </div>
<div class="url-form"> <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 <input
type="text" type="text"
name="q"
id="animeSearchInput" id="animeSearchInput"
placeholder="Rechercher un anime (ex: Frieren, One Piece, Naruto...)" 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"> <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> <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> </svg>
Rechercher Rechercher
</button> </button>
</form>
<div id="search-loading" class="htmx-indicator">
<div class="spinner"></div> Recherche en cours...
</div> </div>
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;"> <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.) 💡 <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 id="animeReleasesList" class="recommendations-carousel"></div>
</div> </div>
<div id="tab-series" class="tab-content"> <div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
<!-- Series Search Section --> <!-- Series Search Section -->
<div class="section-header"> <div class="section-header">
<h2>📺 Rechercher une Série TV</h2> <h2>📺 Rechercher une Série TV</h2>
@@ -105,18 +113,21 @@
<div id="seriesReleasesList" class="releases-carousel"></div> <div id="seriesReleasesList" class="releases-carousel"></div>
</div> </div>
<div id="tab-providers" class="tab-content"> <div id="tab-providers" class="tab-content" x-show="activeTab === 'providers'">
<div class="section-header"> <div class="section-header">
<h2>📦 Fournisseurs de Streaming</h2> <h2>📦 Fournisseurs de Streaming</h2>
<button class="btn btn-sm btn-secondary" hx-get="/api/providers/health" hx-target="#providersGrid">Actualiser</button>
</div> </div>
<div id="providersGrid" class="search-results"></div> <div id="providersGrid" class="search-results"></div>
</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" %} {% include "components/watchlist_section.html" %}
</div> </div>
<div id="tab-downloads" class="tab-content" x-show="activeTab === 'downloads'">
{% include "components/downloads_section.html" %} {% include "components/downloads_section.html" %}
</div>
</div> </div>
<!-- End of main-content --> <!-- End of main-content -->
+23 -64
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ filename }} - Ohm Stream Player</title> <title>{{ filename }} - Ohm Stream Player</title>
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -67,10 +68,8 @@
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
} }
video { .plyr {
width: 100%; border-radius: 15px;
display: block;
max-height: 80vh;
} }
.controls { .controls {
@@ -123,41 +122,13 @@
margin-top: 20px; 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) { @media (max-width: 768px) {
.header h1 { .header h1 {
font-size: 1.2rem; font-size: 1.2rem;
} }
.video-info { flex-direction: column; align-items: flex-start; }
.video-info { .controls { flex-direction: column; }
flex-direction: column; .btn { width: 100%; justify-content: center; }
align-items: flex-start;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
} }
</style> </style>
</head> </head>
@@ -173,12 +144,8 @@
</div> </div>
<div class="video-wrapper"> <div class="video-wrapper">
<video controls preload="metadata"> <video id="player" playsinline controls preload="metadata">
<source src="/stream/{{ filename }}" type="video/mp4"> <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> </video>
</div> </div>
@@ -188,32 +155,24 @@
</div> </div>
</div> </div>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<script> <script>
// Video error handling const player = new Plyr('#player', {
const video = document.querySelector('video'); captions: { active: true, update: true, language: 'auto' },
video.addEventListener('error', (e) => { speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }
console.error('Video error:', e); });
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message'; // Error handling
errorDiv.innerHTML = ` player.on('error', (error) => {
Erreur lors du chargement de la vidéo.<br> console.error('Plyr error:', error);
<a href="/video/{{ task_id }}" style="color: #00d9ff;">Réessayer</a> 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> </script>
</body> </body>