feat: frontend modernization with HTMX, Alpine.js and Plyr (Phase 3)
- 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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,63 +1,81 @@
|
||||
<!-- Downloads Section with Filters -->
|
||||
<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
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
<div class="scheduler-controls">
|
||||
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||
▶️ Démarrer
|
||||
<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 id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||
⏸️ Arrêter
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-get="/api/watchlist"
|
||||
hx-target="#watchlist-items-container">
|
||||
<i class="fas fa-redo"></i> Actualiser
|
||||
</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>
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user