Compare commits
9 Commits
17c7a2e295
...
e42e223f28
| Author | SHA1 | Date | |
|---|---|---|---|
| e42e223f28 | |||
| 9a42442f47 | |||
| 72a0455d59 | |||
| 029abb9be2 | |||
| 34019b7e65 | |||
| 1ca105f330 | |||
| 57da1f1272 | |||
| cf503c8d77 | |||
| b1f4d41b27 |
@ -17,7 +17,8 @@
|
|||||||
"keep_days": 30
|
"keep_days": 30
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$SKlVihGiVIpR6v1fi9H6Xw$rElvHKWqc8WesNfrOJe4CjQI2janLKJPSy6XSOnkq2c"
|
"master_password_hash": "$pbkdf2-sha256$29000$AsCYU2pNCYHwHoPwnlPqXQ$uHLpvUnvj9GmNFgkAAgk3Yvvp2WzLyMNUBwKMyH79CQ",
|
||||||
|
"anime_directory": "/mnt/server/serien/Serien/"
|
||||||
},
|
},
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$MWaMUao1Zuw9hzAmJKS0lg$sV8jdXHeNgzuJEDSbeg/wkwOf5uZpNlYJx3jz/g.eQc"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$2HtvzRljzPk/R2gN4ZwTIg$3E0ARhmzzt..GN4KMmiJpZbIgR0D23bAPX1HF/v4XlQ"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$SanV.v8/x1jL.f8fQwghBA$5qbS2ezRPEPpKwzA71U/yLIyPY6c5JkcRdE.bXAebug"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$eM/5nzPG2Psfo5TSujcGwA$iOo948ox9MUD5.YcCAZoF5Mi1DRzV1OeXXCcEFOFkco"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$TCnlPMe4F2LMmdOa87639g$UGaXOWv2SrWpKoO92Uo5V/Zce07WpHR8qIN8MmTQ8cM"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
1176
instructions.md
1176
instructions.md
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,7 @@ class DownloadStatusEventArgs:
|
|||||||
error: Optional[Exception] = None,
|
error: Optional[Exception] = None,
|
||||||
eta: Optional[int] = None,
|
eta: Optional[int] = None,
|
||||||
mbper_sec: Optional[float] = None,
|
mbper_sec: Optional[float] = None,
|
||||||
|
item_id: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize download status event arguments.
|
Initialize download status event arguments.
|
||||||
@ -47,6 +48,7 @@ class DownloadStatusEventArgs:
|
|||||||
error: Optional error if status is "failed"
|
error: Optional error if status is "failed"
|
||||||
eta: Estimated time remaining in seconds
|
eta: Estimated time remaining in seconds
|
||||||
mbper_sec: Download speed in MB/s
|
mbper_sec: Download speed in MB/s
|
||||||
|
item_id: Optional download queue item ID for tracking
|
||||||
"""
|
"""
|
||||||
self.serie_folder = serie_folder
|
self.serie_folder = serie_folder
|
||||||
self.season = season
|
self.season = season
|
||||||
@ -57,6 +59,7 @@ class DownloadStatusEventArgs:
|
|||||||
self.error = error
|
self.error = error
|
||||||
self.eta = eta
|
self.eta = eta
|
||||||
self.mbper_sec = mbper_sec
|
self.mbper_sec = mbper_sec
|
||||||
|
self.item_id = item_id
|
||||||
|
|
||||||
class ScanStatusEventArgs:
|
class ScanStatusEventArgs:
|
||||||
"""Event arguments for scan status events."""
|
"""Event arguments for scan status events."""
|
||||||
@ -203,6 +206,7 @@ class SeriesApp:
|
|||||||
episode: int,
|
episode: int,
|
||||||
key: str,
|
key: str,
|
||||||
language: str = "German Dub",
|
language: str = "German Dub",
|
||||||
|
item_id: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Download an episode (async).
|
Download an episode (async).
|
||||||
@ -213,6 +217,7 @@ class SeriesApp:
|
|||||||
episode: Episode number
|
episode: Episode number
|
||||||
key: Serie key
|
key: Serie key
|
||||||
language: Language preference
|
language: Language preference
|
||||||
|
item_id: Optional download queue item ID for progress tracking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if download succeeded, False otherwise
|
True if download succeeded, False otherwise
|
||||||
@ -227,6 +232,7 @@ class SeriesApp:
|
|||||||
episode=episode,
|
episode=episode,
|
||||||
status="started",
|
status="started",
|
||||||
message="Download started",
|
message="Download started",
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -254,6 +260,7 @@ class SeriesApp:
|
|||||||
progress=(downloaded / total_bytes) * 100 if total_bytes else 0,
|
progress=(downloaded / total_bytes) * 100 if total_bytes else 0,
|
||||||
eta=eta,
|
eta=eta,
|
||||||
mbper_sec=mbper_sec,
|
mbper_sec=mbper_sec,
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Perform download in thread to avoid blocking event loop
|
# Perform download in thread to avoid blocking event loop
|
||||||
@ -282,6 +289,7 @@ class SeriesApp:
|
|||||||
status="completed",
|
status="completed",
|
||||||
progress=1.0,
|
progress=1.0,
|
||||||
message="Download completed successfully",
|
message="Download completed successfully",
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -297,6 +305,7 @@ class SeriesApp:
|
|||||||
episode=episode,
|
episode=episode,
|
||||||
status="failed",
|
status="failed",
|
||||||
message="Download failed",
|
message="Download failed",
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -321,6 +330,7 @@ class SeriesApp:
|
|||||||
status="failed",
|
status="failed",
|
||||||
error=e,
|
error=e,
|
||||||
message=f"Download error: {str(e)}",
|
message=f"Download error: {str(e)}",
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -3,24 +3,25 @@ Health check controller for monitoring and status endpoints.
|
|||||||
|
|
||||||
This module provides health check endpoints for application monitoring.
|
This module provides health check endpoints for application monitoring.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from src.config.settings import settings
|
||||||
|
from src.server.utils.dependencies import _series_app
|
||||||
from src.core.SeriesApp import SeriesApp
|
|
||||||
from src.server.utils.dependencies import get_series_app
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/health", tags=["health"])
|
router = APIRouter(prefix="/health", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def health_check(
|
async def health_check():
|
||||||
series_app: Optional[SeriesApp] = Depends(get_series_app)
|
"""Health check endpoint for monitoring.
|
||||||
):
|
|
||||||
"""Health check endpoint for monitoring."""
|
This endpoint does not depend on anime_directory configuration
|
||||||
|
and should always return 200 OK for basic health monitoring.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"service": "aniworld-api",
|
"service": "aniworld-api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"series_app_initialized": series_app is not None
|
"series_app_initialized": _series_app is not None,
|
||||||
|
"anime_directory_configured": bool(settings.anime_directory)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,18 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("FastAPI application shutting down")
|
logger.info("FastAPI application shutting down")
|
||||||
|
|
||||||
|
# Shutdown download service and its thread pool
|
||||||
|
try:
|
||||||
|
from src.server.services.download_service import _download_service_instance
|
||||||
|
if _download_service_instance is not None:
|
||||||
|
logger.info("Stopping download service...")
|
||||||
|
await _download_service_instance.stop()
|
||||||
|
logger.info("Download service stopped successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error stopping download service: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
logger.info("FastAPI application shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
# Initialize FastAPI app with lifespan
|
# Initialize FastAPI app with lifespan
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class AnimeService:
|
|||||||
self._app = series_app
|
self._app = series_app
|
||||||
self._directory = series_app.directory_to_search
|
self._directory = series_app.directory_to_search
|
||||||
self._progress_service = progress_service or get_progress_service()
|
self._progress_service = progress_service or get_progress_service()
|
||||||
|
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
# Subscribe to SeriesApp events
|
# Subscribe to SeriesApp events
|
||||||
# Note: Events library uses assignment (=), not += operator
|
# Note: Events library uses assignment (=), not += operator
|
||||||
try:
|
try:
|
||||||
@ -54,49 +55,77 @@ class AnimeService:
|
|||||||
args: DownloadStatusEventArgs from SeriesApp
|
args: DownloadStatusEventArgs from SeriesApp
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if there's a running event loop
|
# Get event loop - try running loop first, then stored loop
|
||||||
|
loop = None
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running loop - log and skip
|
# No running loop in this thread - use stored loop
|
||||||
|
loop = self._event_loop
|
||||||
|
|
||||||
|
if not loop:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No running event loop for download status event",
|
"No event loop available for download status event",
|
||||||
status=args.status
|
status=args.status
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Use item_id if available, otherwise fallback to constructing ID
|
||||||
|
progress_id = (
|
||||||
|
args.item_id
|
||||||
|
if args.item_id
|
||||||
|
else f"download_{args.serie_folder}_{args.season}_{args.episode}"
|
||||||
|
)
|
||||||
|
|
||||||
# Map SeriesApp download events to progress service
|
# Map SeriesApp download events to progress service
|
||||||
if args.status == "started":
|
if args.status == "started":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.start_progress(
|
self._progress_service.start_progress(
|
||||||
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
progress_id=progress_id,
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
progress_type=ProgressType.DOWNLOAD,
|
||||||
title=f"Downloading {args.serie_folder}",
|
title=f"Downloading {args.serie_folder}",
|
||||||
message=f"S{args.season:02d}E{args.episode:02d}",
|
message=f"S{args.season:02d}E{args.episode:02d}",
|
||||||
)
|
metadata={"item_id": args.item_id} if args.item_id else None,
|
||||||
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "progress":
|
elif args.status == "progress":
|
||||||
loop.create_task(
|
# Build metadata with item_id and speed
|
||||||
|
progress_metadata = {}
|
||||||
|
if args.item_id:
|
||||||
|
progress_metadata["item_id"] = args.item_id
|
||||||
|
if args.mbper_sec is not None:
|
||||||
|
progress_metadata["speed_mbps"] = round(args.mbper_sec, 2)
|
||||||
|
if args.eta is not None:
|
||||||
|
progress_metadata["eta"] = args.eta
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.update_progress(
|
self._progress_service.update_progress(
|
||||||
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
progress_id=progress_id,
|
||||||
current=int(args.progress),
|
current=args.progress,
|
||||||
total=100,
|
total=100,
|
||||||
message=args.message or "Downloading...",
|
message=args.message or "Downloading...",
|
||||||
)
|
metadata=(
|
||||||
|
progress_metadata if progress_metadata else None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "completed":
|
elif args.status == "completed":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.complete_progress(
|
self._progress_service.complete_progress(
|
||||||
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
progress_id=progress_id,
|
||||||
message="Download completed",
|
message="Download completed",
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "failed":
|
elif args.status == "failed":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.fail_progress(
|
self._progress_service.fail_progress(
|
||||||
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
progress_id=progress_id,
|
||||||
error_message=args.message or str(args.error),
|
error_message=args.message or str(args.error),
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -113,56 +142,65 @@ class AnimeService:
|
|||||||
try:
|
try:
|
||||||
scan_id = "library_scan"
|
scan_id = "library_scan"
|
||||||
|
|
||||||
# Check if there's a running event loop
|
# Get event loop - try running loop first, then stored loop
|
||||||
|
loop = None
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running loop - log and skip
|
# No running loop in this thread - use stored loop
|
||||||
|
loop = self._event_loop
|
||||||
|
|
||||||
|
if not loop:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No running event loop for scan status event",
|
"No event loop available for scan status event",
|
||||||
status=args.status
|
status=args.status
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Map SeriesApp scan events to progress service
|
# Map SeriesApp scan events to progress service
|
||||||
if args.status == "started":
|
if args.status == "started":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.start_progress(
|
self._progress_service.start_progress(
|
||||||
progress_id=scan_id,
|
progress_id=scan_id,
|
||||||
progress_type=ProgressType.SCAN,
|
progress_type=ProgressType.SCAN,
|
||||||
title="Scanning anime library",
|
title="Scanning anime library",
|
||||||
message=args.message or "Initializing scan...",
|
message=args.message or "Initializing scan...",
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "progress":
|
elif args.status == "progress":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.update_progress(
|
self._progress_service.update_progress(
|
||||||
progress_id=scan_id,
|
progress_id=scan_id,
|
||||||
current=args.current,
|
current=args.current,
|
||||||
total=args.total,
|
total=args.total,
|
||||||
message=args.message or f"Scanning: {args.folder}",
|
message=args.message or f"Scanning: {args.folder}",
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "completed":
|
elif args.status == "completed":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.complete_progress(
|
self._progress_service.complete_progress(
|
||||||
progress_id=scan_id,
|
progress_id=scan_id,
|
||||||
message=args.message or "Scan completed",
|
message=args.message or "Scan completed",
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "failed":
|
elif args.status == "failed":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.fail_progress(
|
self._progress_service.fail_progress(
|
||||||
progress_id=scan_id,
|
progress_id=scan_id,
|
||||||
error_message=args.message or str(args.error),
|
error_message=args.message or str(args.error),
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
elif args.status == "cancelled":
|
elif args.status == "cancelled":
|
||||||
loop.create_task(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._progress_service.fail_progress(
|
self._progress_service.fail_progress(
|
||||||
progress_id=scan_id,
|
progress_id=scan_id,
|
||||||
error_message=args.message or "Scan cancelled",
|
error_message=args.message or "Scan cancelled",
|
||||||
)
|
),
|
||||||
|
loop
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error handling scan status event", error=str(exc))
|
logger.error("Error handling scan status event", error=str(exc))
|
||||||
@ -219,6 +257,9 @@ class AnimeService:
|
|||||||
forwarded to the ProgressService through event handlers.
|
forwarded to the ProgressService through event handlers.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Store event loop for event handlers
|
||||||
|
self._event_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
# SeriesApp.rescan is now async and handles events internally
|
# SeriesApp.rescan is now async and handles events internally
|
||||||
await self._app.rescan()
|
await self._app.rescan()
|
||||||
|
|
||||||
@ -238,21 +279,33 @@ class AnimeService:
|
|||||||
season: int,
|
season: int,
|
||||||
episode: int,
|
episode: int,
|
||||||
key: str,
|
key: str,
|
||||||
|
item_id: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Start a download.
|
"""Start a download.
|
||||||
|
|
||||||
The SeriesApp now handles progress tracking via events which are
|
The SeriesApp now handles progress tracking via events which are
|
||||||
forwarded to the ProgressService through event handlers.
|
forwarded to the ProgressService through event handlers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_folder: Serie folder name
|
||||||
|
season: Season number
|
||||||
|
episode: Episode number
|
||||||
|
key: Serie key
|
||||||
|
item_id: Optional download queue item ID for tracking
|
||||||
|
|
||||||
Returns True on success or raises AnimeServiceError on failure.
|
Returns True on success or raises AnimeServiceError on failure.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Store event loop for event handlers
|
||||||
|
self._event_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
# SeriesApp.download is now async and handles events internally
|
# SeriesApp.download is now async and handles events internally
|
||||||
return await self._app.download(
|
return await self._app.download(
|
||||||
serie_folder=serie_folder,
|
serie_folder=serie_folder,
|
||||||
season=season,
|
season=season,
|
||||||
episode=episode,
|
episode=episode,
|
||||||
key=key,
|
key=key,
|
||||||
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("download failed")
|
logger.exception("download failed")
|
||||||
|
|||||||
@ -77,10 +77,14 @@ class DownloadService:
|
|||||||
|
|
||||||
# Control flags
|
# Control flags
|
||||||
self._is_stopped = True # Queue processing is stopped by default
|
self._is_stopped = True # Queue processing is stopped by default
|
||||||
|
self._is_shutting_down = False # Flag to indicate shutdown
|
||||||
|
|
||||||
# Executor for blocking operations
|
# Executor for blocking operations
|
||||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
|
||||||
|
# Track active download task for cancellation
|
||||||
|
self._active_download_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
self._total_downloaded_mb: float = 0.0
|
self._total_downloaded_mb: float = 0.0
|
||||||
self._download_speeds: deque[float] = deque(maxlen=10)
|
self._download_speeds: deque[float] = deque(maxlen=10)
|
||||||
@ -500,7 +504,11 @@ class DownloadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Process the download (this will wait until complete)
|
# Process the download (this will wait until complete)
|
||||||
await self._process_download(item)
|
self._active_download_task = asyncio.create_task(
|
||||||
|
self._process_download(item)
|
||||||
|
)
|
||||||
|
await self._active_download_task
|
||||||
|
self._active_download_task = None
|
||||||
|
|
||||||
# Small delay between downloads
|
# Small delay between downloads
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
@ -771,6 +779,11 @@ class DownloadService:
|
|||||||
item: Download item to process
|
item: Download item to process
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Check if shutting down
|
||||||
|
if self._is_shutting_down:
|
||||||
|
logger.info("Skipping download due to shutdown")
|
||||||
|
return
|
||||||
|
|
||||||
# Update status
|
# Update status
|
||||||
item.status = DownloadStatus.DOWNLOADING
|
item.status = DownloadStatus.DOWNLOADING
|
||||||
item.started_at = datetime.now(timezone.utc)
|
item.started_at = datetime.now(timezone.utc)
|
||||||
@ -795,6 +808,7 @@ class DownloadService:
|
|||||||
season=item.episode.season,
|
season=item.episode.season,
|
||||||
episode=item.episode.episode,
|
episode=item.episode.episode,
|
||||||
key=item.serie_id,
|
key=item.serie_id,
|
||||||
|
item_id=item.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle result
|
# Handle result
|
||||||
@ -814,6 +828,19 @@ class DownloadService:
|
|||||||
else:
|
else:
|
||||||
raise AnimeServiceError("Download returned False")
|
raise AnimeServiceError("Download returned False")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Handle task cancellation during shutdown
|
||||||
|
logger.info(
|
||||||
|
"Download cancelled during shutdown",
|
||||||
|
item_id=item.id,
|
||||||
|
)
|
||||||
|
item.status = DownloadStatus.CANCELLED
|
||||||
|
item.completed_at = datetime.now(timezone.utc)
|
||||||
|
# Return item to pending queue if not shutting down
|
||||||
|
if not self._is_shutting_down:
|
||||||
|
self._add_to_pending_queue(item, front=True)
|
||||||
|
raise # Re-raise to properly cancel the task
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Handle failure
|
# Handle failure
|
||||||
item.status = DownloadStatus.FAILED
|
item.status = DownloadStatus.FAILED
|
||||||
@ -845,27 +872,31 @@ class DownloadService:
|
|||||||
logger.info("Download queue service initialized")
|
logger.info("Download queue service initialized")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the download queue service and wait for active download.
|
"""Stop the download queue service and cancel active downloads.
|
||||||
|
|
||||||
Note: This waits for the current download to complete.
|
Cancels any active download and shuts down the thread pool immediately.
|
||||||
"""
|
"""
|
||||||
logger.info("Stopping download queue service...")
|
logger.info("Stopping download queue service...")
|
||||||
|
|
||||||
# Wait for active download to complete (with timeout)
|
# Set shutdown flag
|
||||||
timeout = 30 # seconds
|
self._is_shutting_down = True
|
||||||
start_time = asyncio.get_event_loop().time()
|
self._is_stopped = True
|
||||||
|
|
||||||
while (
|
# Cancel active download task if running
|
||||||
self._active_download
|
if self._active_download_task and not self._active_download_task.done():
|
||||||
and (asyncio.get_event_loop().time() - start_time) < timeout
|
logger.info("Cancelling active download task...")
|
||||||
):
|
self._active_download_task.cancel()
|
||||||
await asyncio.sleep(1)
|
try:
|
||||||
|
await self._active_download_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Active download task cancelled")
|
||||||
|
|
||||||
# Save final state
|
# Save final state
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
|
|
||||||
# Shutdown executor
|
# Shutdown executor immediately, don't wait for tasks
|
||||||
self._executor.shutdown(wait=True)
|
logger.info("Shutting down thread pool executor...")
|
||||||
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
logger.info("Download queue service stopped")
|
logger.info("Download queue service stopped")
|
||||||
|
|
||||||
|
|||||||
@ -48,8 +48,15 @@ class QueueManager {
|
|||||||
this.updateQueueDisplay(data);
|
this.updateQueueDisplay(data);
|
||||||
});
|
});
|
||||||
this.socket.on('queue_status', (data) => {
|
this.socket.on('queue_status', (data) => {
|
||||||
// New backend sends queue_status messages
|
// New backend sends queue_status messages with nested structure
|
||||||
if (data.queue_status) {
|
if (data.status && data.statistics) {
|
||||||
|
// Transform nested structure to flat structure
|
||||||
|
const queueData = {
|
||||||
|
...data.status,
|
||||||
|
statistics: data.statistics
|
||||||
|
};
|
||||||
|
this.updateQueueDisplay(queueData);
|
||||||
|
} else if (data.queue_status) {
|
||||||
this.updateQueueDisplay(data.queue_status);
|
this.updateQueueDisplay(data.queue_status);
|
||||||
} else {
|
} else {
|
||||||
this.updateQueueDisplay(data);
|
this.updateQueueDisplay(data);
|
||||||
@ -228,10 +235,20 @@ class QueueManager {
|
|||||||
async loadQueueData() {
|
async loadQueueData() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/status');
|
const response = await this.makeAuthenticatedRequest('/api/queue/status');
|
||||||
if (!response) return;
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.updateQueueDisplay(data);
|
|
||||||
|
// API returns nested structure with 'status' and 'statistics'
|
||||||
|
// Transform it to the expected flat structure
|
||||||
|
const queueData = {
|
||||||
|
...data.status, // includes is_running, active_downloads, pending_queue, etc.
|
||||||
|
statistics: data.statistics
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateQueueDisplay(queueData);
|
||||||
|
|
||||||
// Process any pending progress updates after queue is loaded
|
// Process any pending progress updates after queue is loaded
|
||||||
this.processPendingProgressUpdates();
|
this.processPendingProgressUpdates();
|
||||||
@ -376,6 +393,7 @@ class QueueManager {
|
|||||||
// Extract progress information - handle both ProgressService and yt-dlp formats
|
// Extract progress information - handle both ProgressService and yt-dlp formats
|
||||||
const progress = data.progress || data;
|
const progress = data.progress || data;
|
||||||
const percent = progress.percent || 0;
|
const percent = progress.percent || 0;
|
||||||
|
const metadata = progress.metadata || data.metadata || {};
|
||||||
|
|
||||||
// Check if we have detailed yt-dlp progress (downloaded_mb, total_mb, speed_mbps)
|
// Check if we have detailed yt-dlp progress (downloaded_mb, total_mb, speed_mbps)
|
||||||
// or basic ProgressService progress (current, total)
|
// or basic ProgressService progress (current, total)
|
||||||
@ -390,12 +408,13 @@ class QueueManager {
|
|||||||
// ProgressService basic format - convert bytes to MB
|
// ProgressService basic format - convert bytes to MB
|
||||||
downloaded = (progress.current / (1024 * 1024)).toFixed(1);
|
downloaded = (progress.current / (1024 * 1024)).toFixed(1);
|
||||||
total = progress.total > 0 ? (progress.total / (1024 * 1024)).toFixed(1) : 'Unknown';
|
total = progress.total > 0 ? (progress.total / (1024 * 1024)).toFixed(1) : 'Unknown';
|
||||||
speed = '0.0'; // Speed not available in basic format
|
// Check for speed in metadata
|
||||||
|
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
downloaded = '0.0';
|
downloaded = '0.0';
|
||||||
total = 'Unknown';
|
total = 'Unknown';
|
||||||
speed = '0.0';
|
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress bar
|
// Update progress bar
|
||||||
@ -411,7 +430,7 @@ class QueueManager {
|
|||||||
const speedSpan = progressInfo.querySelector('.download-speed');
|
const speedSpan = progressInfo.querySelector('.download-speed');
|
||||||
|
|
||||||
if (percentSpan) {
|
if (percentSpan) {
|
||||||
percentSpan.textContent = `${percent.toFixed(1)}% (${downloaded} MB / ${total} MB)`;
|
percentSpan.textContent = percent > 0 ? `${percent.toFixed(1)}%` : 'Starting...';
|
||||||
}
|
}
|
||||||
if (speedSpan) {
|
if (speedSpan) {
|
||||||
speedSpan.textContent = `${speed} MB/s`;
|
speedSpan.textContent = `${speed} MB/s`;
|
||||||
@ -470,8 +489,8 @@ class QueueManager {
|
|||||||
const progress = download.progress || {};
|
const progress = download.progress || {};
|
||||||
const progressPercent = progress.percent || 0;
|
const progressPercent = progress.percent || 0;
|
||||||
const speed = progress.speed_mbps ? `${progress.speed_mbps.toFixed(1)} MB/s` : '0 MB/s';
|
const speed = progress.speed_mbps ? `${progress.speed_mbps.toFixed(1)} MB/s` : '0 MB/s';
|
||||||
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0 MB';
|
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0.0';
|
||||||
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
|
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : '0.0';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-card active" data-download-id="${download.id}">
|
<div class="download-card active" data-download-id="${download.id}">
|
||||||
@ -486,7 +505,7 @@ class QueueManager {
|
|||||||
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>${progressPercent.toFixed(1)}% (${downloaded} / ${total})</span>
|
<span>${progressPercent > 0 ? `${progressPercent.toFixed(1)}%` : 'Starting...'}</span>
|
||||||
<span class="download-speed">${speed}</span>
|
<span class="download-speed">${speed}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -442,15 +442,18 @@ class TestFrontendJavaScriptIntegration:
|
|||||||
|
|
||||||
async def test_queue_operations_compatibility(self, authenticated_client):
|
async def test_queue_operations_compatibility(self, authenticated_client):
|
||||||
"""Test queue operations match queue.js expectations."""
|
"""Test queue operations match queue.js expectations."""
|
||||||
# Test start
|
# Test start - should return 400 when queue is empty (valid behavior)
|
||||||
response = await authenticated_client.post("/api/queue/start")
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
assert response.status_code == 200
|
assert response.status_code in [200, 400]
|
||||||
|
if response.status_code == 400:
|
||||||
|
# Verify error message indicates empty queue
|
||||||
|
assert "No pending downloads" in response.json()["detail"]
|
||||||
|
|
||||||
# Test pause
|
# Test pause - always succeeds even if nothing is processing
|
||||||
response = await authenticated_client.post("/api/queue/pause")
|
response = await authenticated_client.post("/api/queue/pause")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test stop
|
# Test stop - always succeeds even if nothing is processing
|
||||||
response = await authenticated_client.post("/api/queue/stop")
|
response = await authenticated_client.post("/api/queue/stop")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|||||||
@ -26,11 +26,33 @@ from src.server.models.download import (
|
|||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService
|
from src.server.services.anime_service import AnimeService
|
||||||
from src.server.services.auth_service import auth_service
|
from src.server.services.auth_service import auth_service
|
||||||
|
from src.server.services.config_service import get_config_service
|
||||||
from src.server.services.download_service import DownloadService
|
from src.server.services.download_service import DownloadService
|
||||||
from src.server.services.progress_service import get_progress_service
|
from src.server.services.progress_service import get_progress_service
|
||||||
from src.server.services.websocket_service import get_websocket_service
|
from src.server.services.websocket_service import get_websocket_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_temp_config(tmp_path):
|
||||||
|
"""Setup temporary config directory for tests."""
|
||||||
|
config_service = get_config_service()
|
||||||
|
original_path = config_service.config_path
|
||||||
|
original_backup_dir = config_service.backup_dir
|
||||||
|
|
||||||
|
# Set temporary paths
|
||||||
|
temp_data = tmp_path / "data"
|
||||||
|
temp_data.mkdir(exist_ok=True)
|
||||||
|
config_service.config_path = temp_data / "config.json"
|
||||||
|
config_service.backup_dir = temp_data / "config_backups"
|
||||||
|
config_service.backup_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Restore original paths
|
||||||
|
config_service.config_path = original_path
|
||||||
|
config_service.backup_dir = original_backup_dir
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_auth():
|
def reset_auth():
|
||||||
"""Reset authentication state before each test."""
|
"""Reset authentication state before each test."""
|
||||||
|
|||||||
@ -8,6 +8,7 @@ concurrent requests and maintain acceptable response times.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@ -20,6 +21,22 @@ from src.server.fastapi_app import app
|
|||||||
class TestAPILoadTesting:
|
class TestAPILoadTesting:
|
||||||
"""Load testing for API endpoints."""
|
"""Load testing for API endpoints."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_series_app_dependency(self):
|
||||||
|
"""Mock SeriesApp dependency for performance tests."""
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list = MagicMock()
|
||||||
|
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
|
||||||
|
mock_app.search = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
app.dependency_overrides[get_series_app] = lambda: mock_app
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(self):
|
async def client(self):
|
||||||
"""Create async HTTP client."""
|
"""Create async HTTP client."""
|
||||||
|
|||||||
@ -17,12 +17,28 @@ class TestSQLInjection:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(self):
|
async def client(self):
|
||||||
"""Create async HTTP client for testing."""
|
"""Create async HTTP client for testing."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from httpx import ASGITransport
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
# Mock SeriesApp to avoid 503 errors
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list = MagicMock()
|
||||||
|
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
|
||||||
|
mock_app.search = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Override dependency
|
||||||
|
app.dependency_overrides[get_series_app] = lambda: mock_app
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=ASGITransport(app=app), base_url="http://test"
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
) as ac:
|
) as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
# Classic SQL Injection payloads
|
# Classic SQL Injection payloads
|
||||||
SQL_INJECTION_PAYLOADS = [
|
SQL_INJECTION_PAYLOADS = [
|
||||||
@ -138,12 +154,28 @@ class TestNoSQLInjection:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(self):
|
async def client(self):
|
||||||
"""Create async HTTP client for testing."""
|
"""Create async HTTP client for testing."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from httpx import ASGITransport
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
# Mock SeriesApp to avoid 503 errors
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list = MagicMock()
|
||||||
|
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
|
||||||
|
mock_app.search = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Override dependency
|
||||||
|
app.dependency_overrides[get_series_app] = lambda: mock_app
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=ASGITransport(app=app), base_url="http://test"
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
) as ac:
|
) as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_nosql_injection_in_query(self, client):
|
async def test_nosql_injection_in_query(self, client):
|
||||||
@ -240,17 +272,33 @@ class TestORMInjection:
|
|||||||
|
|
||||||
@pytest.mark.security
|
@pytest.mark.security
|
||||||
class TestDatabaseSecurity:
|
class TestDatabaseSecurity:
|
||||||
"""General database security tests."""
|
"""Security tests for database access patterns."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(self):
|
async def client(self):
|
||||||
"""Create async HTTP client for testing."""
|
"""Create async HTTP client for testing."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from httpx import ASGITransport
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
# Mock SeriesApp to avoid 503 errors
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list = MagicMock()
|
||||||
|
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
|
||||||
|
mock_app.search = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Override dependency
|
||||||
|
app.dependency_overrides[get_series_app] = lambda: mock_app
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=ASGITransport(app=app), base_url="http://test"
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
) as ac:
|
) as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_error_messages_no_leak_info(self, client):
|
async def test_error_messages_no_leak_info(self, client):
|
||||||
|
|||||||
@ -247,6 +247,7 @@ class TestDownload:
|
|||||||
season=1,
|
season=1,
|
||||||
episode=1,
|
episode=1,
|
||||||
key="test_key",
|
key="test_key",
|
||||||
|
item_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -272,6 +273,7 @@ class TestDownload:
|
|||||||
season=1,
|
season=1,
|
||||||
episode=1,
|
episode=1,
|
||||||
key="test_key",
|
key="test_key",
|
||||||
|
item_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"""Unit tests for setup redirect middleware."""
|
"""Unit tests for setup redirect middleware."""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
||||||
from src.server.services.auth_service import auth_service
|
from src.server.services.auth_service import auth_service
|
||||||
@ -46,9 +45,11 @@ def app():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(app):
|
async def client(app):
|
||||||
"""Create a test client."""
|
"""Create an async test client."""
|
||||||
return TestClient(app)
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -95,10 +96,11 @@ def reset_config_service():
|
|||||||
class TestSetupRedirectMiddleware:
|
class TestSetupRedirectMiddleware:
|
||||||
"""Test cases for setup redirect middleware."""
|
"""Test cases for setup redirect middleware."""
|
||||||
|
|
||||||
def test_redirect_to_setup_when_not_configured(self, client):
|
@pytest.mark.asyncio
|
||||||
"""Test that HTML requests are redirected to /setup when not configured."""
|
async def test_redirect_to_setup_when_not_configured(self, client):
|
||||||
|
"""Test that HTML requests redirect to /setup when not configured."""
|
||||||
# Request home page with HTML accept header (don't follow redirects)
|
# Request home page with HTML accept header (don't follow redirects)
|
||||||
response = client.get(
|
response = await client.get(
|
||||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -106,36 +108,40 @@ class TestSetupRedirectMiddleware:
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.headers["location"] == "/setup"
|
assert response.headers["location"] == "/setup"
|
||||||
|
|
||||||
def test_setup_page_accessible_without_config(self, client):
|
@pytest.mark.asyncio
|
||||||
"""Test that /setup page is accessible even when not configured."""
|
async def test_setup_page_accessible_without_config(self, client):
|
||||||
response = client.get("/setup")
|
"""Test that /setup page is accessible when not configured."""
|
||||||
|
response = await client.get("/setup")
|
||||||
|
|
||||||
# Should not redirect
|
# Should not redirect
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["message"] == "Setup page"
|
assert response.json()["message"] == "Setup page"
|
||||||
|
|
||||||
def test_api_returns_503_when_not_configured(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_returns_503_when_not_configured(self, client):
|
||||||
"""Test that API requests return 503 when not configured."""
|
"""Test that API requests return 503 when not configured."""
|
||||||
response = client.get("/api/data")
|
response = await client.get("/api/data")
|
||||||
|
|
||||||
# Should return 503 Service Unavailable
|
# Should return 503 Service Unavailable
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
assert "setup_url" in response.json()
|
assert "setup_url" in response.json()
|
||||||
assert response.json()["setup_url"] == "/setup"
|
assert response.json()["setup_url"] == "/setup"
|
||||||
|
|
||||||
def test_exempt_api_endpoints_accessible(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_exempt_api_endpoints_accessible(self, client):
|
||||||
"""Test that exempt API endpoints are accessible without setup."""
|
"""Test that exempt API endpoints are accessible without setup."""
|
||||||
# Health endpoint should be accessible
|
# Health endpoint should be accessible
|
||||||
response = client.get("/api/health")
|
response = await client.get("/api/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "ok"
|
assert response.json()["status"] == "ok"
|
||||||
|
|
||||||
# Auth status endpoint should be accessible
|
# Auth status endpoint should be accessible
|
||||||
response = client.get("/api/auth/status")
|
response = await client.get("/api/auth/status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["configured"] is False
|
assert response.json()["configured"] is False
|
||||||
|
|
||||||
def test_no_redirect_when_configured(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_redirect_when_configured(self, client):
|
||||||
"""Test that no redirect happens when auth and config are set up."""
|
"""Test that no redirect happens when auth and config are set up."""
|
||||||
# Configure auth service
|
# Configure auth service
|
||||||
auth_service.setup_master_password("Test@Password123")
|
auth_service.setup_master_password("Test@Password123")
|
||||||
@ -147,13 +153,14 @@ class TestSetupRedirectMiddleware:
|
|||||||
config_service.save_config(config, create_backup=False)
|
config_service.save_config(config, create_backup=False)
|
||||||
|
|
||||||
# Request home page
|
# Request home page
|
||||||
response = client.get("/", headers={"Accept": "text/html"})
|
response = await client.get("/", headers={"Accept": "text/html"})
|
||||||
|
|
||||||
# Should not redirect
|
# Should not redirect
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["message"] == "Home page"
|
assert response.json()["message"] == "Home page"
|
||||||
|
|
||||||
def test_api_works_when_configured(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_works_when_configured(self, client):
|
||||||
"""Test that API requests work normally when configured."""
|
"""Test that API requests work normally when configured."""
|
||||||
# Configure auth service
|
# Configure auth service
|
||||||
auth_service.setup_master_password("Test@Password123")
|
auth_service.setup_master_password("Test@Password123")
|
||||||
@ -165,44 +172,44 @@ class TestSetupRedirectMiddleware:
|
|||||||
config_service.save_config(config, create_backup=False)
|
config_service.save_config(config, create_backup=False)
|
||||||
|
|
||||||
# Request API endpoint
|
# Request API endpoint
|
||||||
response = client.get("/api/data")
|
response = await client.get("/api/data")
|
||||||
|
|
||||||
# Should work normally
|
# Should work normally
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["data"] == "some data"
|
assert response.json()["data"] == "some data"
|
||||||
|
|
||||||
def test_static_files_always_accessible(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_static_files_always_accessible(self, client, app):
|
||||||
"""Test that static file paths are always accessible."""
|
"""Test that static file paths are always accessible."""
|
||||||
# Create a route that mimics static file serving
|
# Create a route that mimics static file serving
|
||||||
from fastapi import FastAPI
|
|
||||||
app = client.app
|
|
||||||
|
|
||||||
@app.get("/static/css/style.css")
|
@app.get("/static/css/style.css")
|
||||||
async def static_css():
|
async def static_css():
|
||||||
return {"content": "css"}
|
return {"content": "css"}
|
||||||
|
|
||||||
# Request static file
|
# Request static file
|
||||||
response = client.get("/static/css/style.css")
|
response = await client.get("/static/css/style.css")
|
||||||
|
|
||||||
# Should be accessible even without setup
|
# Should be accessible even without setup
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_redirect_when_only_auth_configured(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_redirect_when_only_auth_configured(self, client):
|
||||||
"""Test redirect when auth is configured but config is invalid."""
|
"""Test redirect when auth is configured but config is invalid."""
|
||||||
# Configure auth but don't create config file
|
# Configure auth but don't create config file
|
||||||
auth_service.setup_master_password("Test@Password123")
|
auth_service.setup_master_password("Test@Password123")
|
||||||
|
|
||||||
# Request home page
|
# Request home page
|
||||||
response = client.get("/", headers={"Accept": "text/html"})
|
response = await client.get("/", headers={"Accept": "text/html"})
|
||||||
|
|
||||||
# Should still work because load_config creates default config
|
# Should still work because load_config creates default config
|
||||||
# This is the current behavior - may need to adjust if we want
|
# This is the current behavior - may need to adjust if we want
|
||||||
# stricter setup requirements
|
# stricter setup requirements
|
||||||
assert response.status_code in [200, 302]
|
assert response.status_code in [200, 302]
|
||||||
|
|
||||||
def test_root_path_redirect(self, client):
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_path_redirect(self, client):
|
||||||
"""Test that root path redirects to setup when not configured."""
|
"""Test that root path redirects to setup when not configured."""
|
||||||
response = client.get(
|
response = await client.get(
|
||||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -210,8 +217,8 @@ class TestSetupRedirectMiddleware:
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.headers["location"] == "/setup"
|
assert response.headers["location"] == "/setup"
|
||||||
|
|
||||||
def test_path_matching_exact_and_prefix(self, client):
|
def test_path_matching_exact_and_prefix(self):
|
||||||
"""Test that path matching works for both exact and prefix matches."""
|
"""Test that path matching works for both exact and prefix."""
|
||||||
middleware = SetupRedirectMiddleware(app=FastAPI())
|
middleware = SetupRedirectMiddleware(app=FastAPI())
|
||||||
|
|
||||||
# Exact matches
|
# Exact matches
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user