Compare commits
No commits in common. "e42e223f282f285edd46a5ff9819705170e040cf" and "17c7a2e29542f68f469b4d13a2d0db6027b6e5b5" have entirely different histories.
e42e223f28
...
17c7a2e295
@ -17,8 +17,7 @@
|
|||||||
"keep_days": 30
|
"keep_days": 30
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$AsCYU2pNCYHwHoPwnlPqXQ$uHLpvUnvj9GmNFgkAAgk3Yvvp2WzLyMNUBwKMyH79CQ",
|
"master_password_hash": "$pbkdf2-sha256$29000$SKlVihGiVIpR6v1fi9H6Xw$rElvHKWqc8WesNfrOJe4CjQI2janLKJPSy6XSOnkq2c"
|
||||||
"anime_directory": "/mnt/server/serien/Serien/"
|
|
||||||
},
|
},
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
23
data/config_backups/config_backup_20251115_165646.json
Normal file
23
data/config_backups/config_backup_20251115_165646.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
23
data/config_backups/config_backup_20251115_170321.json
Normal file
23
data/config_backups/config_backup_20251115_170321.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
23
data/config_backups/config_backup_20251115_174018.json
Normal file
23
data/config_backups/config_backup_20251115_174018.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
23
data/config_backups/config_backup_20251115_174158.json
Normal file
23
data/config_backups/config_backup_20251115_174158.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
23
data/config_backups/config_backup_20251115_175335.json
Normal file
23
data/config_backups/config_backup_20251115_175335.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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,7 +33,6 @@ 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.
|
||||||
@ -48,7 +47,6 @@ 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
|
||||||
@ -59,7 +57,6 @@ 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."""
|
||||||
@ -206,7 +203,6 @@ 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).
|
||||||
@ -217,7 +213,6 @@ 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
|
||||||
@ -232,7 +227,6 @@ class SeriesApp:
|
|||||||
episode=episode,
|
episode=episode,
|
||||||
status="started",
|
status="started",
|
||||||
message="Download started",
|
message="Download started",
|
||||||
item_id=item_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -260,7 +254,6 @@ 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
|
||||||
@ -289,7 +282,6 @@ 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:
|
||||||
@ -305,7 +297,6 @@ class SeriesApp:
|
|||||||
episode=episode,
|
episode=episode,
|
||||||
status="failed",
|
status="failed",
|
||||||
message="Download failed",
|
message="Download failed",
|
||||||
item_id=item_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -330,7 +321,6 @@ 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,25 +3,24 @@ 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 fastapi import APIRouter
|
from typing import Optional
|
||||||
|
|
||||||
from src.config.settings import settings
|
from fastapi import APIRouter, Depends
|
||||||
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(
|
||||||
"""Health check endpoint for monitoring.
|
series_app: Optional[SeriesApp] = Depends(get_series_app)
|
||||||
|
):
|
||||||
This endpoint does not depend on anime_directory configuration
|
"""Health check endpoint for monitoring."""
|
||||||
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,18 +100,6 @@ 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,7 +37,6 @@ 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:
|
||||||
@ -55,77 +54,49 @@ class AnimeService:
|
|||||||
args: DownloadStatusEventArgs from SeriesApp
|
args: DownloadStatusEventArgs from SeriesApp
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get event loop - try running loop first, then stored loop
|
# Check if there's a running event loop
|
||||||
loop = None
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running loop in this thread - use stored loop
|
# No running loop - log and skip
|
||||||
loop = self._event_loop
|
|
||||||
|
|
||||||
if not loop:
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No event loop available for download status event",
|
"No running event loop 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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
self._progress_service.start_progress(
|
self._progress_service.start_progress(
|
||||||
progress_id=progress_id,
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
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":
|
||||||
# Build metadata with item_id and speed
|
loop.create_task(
|
||||||
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=progress_id,
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
current=args.progress,
|
current=int(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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
self._progress_service.complete_progress(
|
self._progress_service.complete_progress(
|
||||||
progress_id=progress_id,
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
message="Download completed",
|
message="Download completed",
|
||||||
),
|
)
|
||||||
loop
|
|
||||||
)
|
)
|
||||||
elif args.status == "failed":
|
elif args.status == "failed":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
self._progress_service.fail_progress(
|
self._progress_service.fail_progress(
|
||||||
progress_id=progress_id,
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
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(
|
||||||
@ -142,65 +113,56 @@ class AnimeService:
|
|||||||
try:
|
try:
|
||||||
scan_id = "library_scan"
|
scan_id = "library_scan"
|
||||||
|
|
||||||
# Get event loop - try running loop first, then stored loop
|
# Check if there's a running event loop
|
||||||
loop = None
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running loop in this thread - use stored loop
|
# No running loop - log and skip
|
||||||
loop = self._event_loop
|
|
||||||
|
|
||||||
if not loop:
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No event loop available for scan status event",
|
"No running event loop 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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
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":
|
||||||
asyncio.run_coroutine_threadsafe(
|
loop.create_task(
|
||||||
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))
|
||||||
@ -257,9 +219,6 @@ 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()
|
||||||
|
|
||||||
@ -279,33 +238,21 @@ 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,14 +77,10 @@ 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)
|
||||||
@ -504,11 +500,7 @@ class DownloadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Process the download (this will wait until complete)
|
# Process the download (this will wait until complete)
|
||||||
self._active_download_task = asyncio.create_task(
|
await self._process_download(item)
|
||||||
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)
|
||||||
@ -779,11 +771,6 @@ 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)
|
||||||
@ -808,7 +795,6 @@ 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
|
||||||
@ -828,19 +814,6 @@ 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
|
||||||
@ -872,31 +845,27 @@ 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 cancel active downloads.
|
"""Stop the download queue service and wait for active download.
|
||||||
|
|
||||||
Cancels any active download and shuts down the thread pool immediately.
|
Note: This waits for the current download to complete.
|
||||||
"""
|
"""
|
||||||
logger.info("Stopping download queue service...")
|
logger.info("Stopping download queue service...")
|
||||||
|
|
||||||
# Set shutdown flag
|
# Wait for active download to complete (with timeout)
|
||||||
self._is_shutting_down = True
|
timeout = 30 # seconds
|
||||||
self._is_stopped = True
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
# Cancel active download task if running
|
while (
|
||||||
if self._active_download_task and not self._active_download_task.done():
|
self._active_download
|
||||||
logger.info("Cancelling active download task...")
|
and (asyncio.get_event_loop().time() - start_time) < timeout
|
||||||
self._active_download_task.cancel()
|
):
|
||||||
try:
|
await asyncio.sleep(1)
|
||||||
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 immediately, don't wait for tasks
|
# Shutdown executor
|
||||||
logger.info("Shutting down thread pool executor...")
|
self._executor.shutdown(wait=True)
|
||||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
|
|
||||||
logger.info("Download queue service stopped")
|
logger.info("Download queue service stopped")
|
||||||
|
|
||||||
|
|||||||
@ -48,15 +48,8 @@ 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 with nested structure
|
// New backend sends queue_status messages
|
||||||
if (data.status && data.statistics) {
|
if (data.queue_status) {
|
||||||
// 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);
|
||||||
@ -235,20 +228,10 @@ 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) {
|
if (!response) return;
|
||||||
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();
|
||||||
@ -393,7 +376,6 @@ 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)
|
||||||
@ -408,13 +390,12 @@ 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';
|
||||||
// Check for speed in metadata
|
speed = '0.0'; // Speed not available in basic format
|
||||||
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 = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
|
speed = '0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress bar
|
// Update progress bar
|
||||||
@ -430,7 +411,7 @@ class QueueManager {
|
|||||||
const speedSpan = progressInfo.querySelector('.download-speed');
|
const speedSpan = progressInfo.querySelector('.download-speed');
|
||||||
|
|
||||||
if (percentSpan) {
|
if (percentSpan) {
|
||||||
percentSpan.textContent = percent > 0 ? `${percent.toFixed(1)}%` : 'Starting...';
|
percentSpan.textContent = `${percent.toFixed(1)}% (${downloaded} MB / ${total} MB)`;
|
||||||
}
|
}
|
||||||
if (speedSpan) {
|
if (speedSpan) {
|
||||||
speedSpan.textContent = `${speed} MB/s`;
|
speedSpan.textContent = `${speed} MB/s`;
|
||||||
@ -489,8 +470,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.0';
|
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0 MB';
|
||||||
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : '0.0';
|
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-card active" data-download-id="${download.id}">
|
<div class="download-card active" data-download-id="${download.id}">
|
||||||
@ -505,7 +486,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 > 0 ? `${progressPercent.toFixed(1)}%` : 'Starting...'}</span>
|
<span>${progressPercent.toFixed(1)}% (${downloaded} / ${total})</span>
|
||||||
<span class="download-speed">${speed}</span>
|
<span class="download-speed">${speed}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -442,18 +442,15 @@ 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 - should return 400 when queue is empty (valid behavior)
|
# Test start
|
||||||
response = await authenticated_client.post("/api/queue/start")
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
assert response.status_code in [200, 400]
|
assert response.status_code == 200
|
||||||
if response.status_code == 400:
|
|
||||||
# Verify error message indicates empty queue
|
|
||||||
assert "No pending downloads" in response.json()["detail"]
|
|
||||||
|
|
||||||
# Test pause - always succeeds even if nothing is processing
|
# Test pause
|
||||||
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 - always succeeds even if nothing is processing
|
# Test stop
|
||||||
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,33 +26,11 @@ 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,7 +8,6 @@ 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
|
||||||
@ -21,22 +20,6 @@ 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,28 +17,12 @@ 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 = [
|
||||||
@ -154,28 +138,12 @@ 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):
|
||||||
@ -272,33 +240,17 @@ class TestORMInjection:
|
|||||||
|
|
||||||
@pytest.mark.security
|
@pytest.mark.security
|
||||||
class TestDatabaseSecurity:
|
class TestDatabaseSecurity:
|
||||||
"""Security tests for database access patterns."""
|
"""General database security tests."""
|
||||||
|
|
||||||
@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,7 +247,6 @@ 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
|
||||||
@ -273,7 +272,6 @@ 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,7 +1,8 @@
|
|||||||
"""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 httpx import ASGITransport, AsyncClient
|
from fastapi.testclient import TestClient
|
||||||
|
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
|
||||||
@ -45,11 +46,9 @@ def app():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(app):
|
def client(app):
|
||||||
"""Create an async test client."""
|
"""Create a test client."""
|
||||||
transport = ASGITransport(app=app)
|
return TestClient(app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
yield ac
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -96,11 +95,10 @@ def reset_config_service():
|
|||||||
class TestSetupRedirectMiddleware:
|
class TestSetupRedirectMiddleware:
|
||||||
"""Test cases for setup redirect middleware."""
|
"""Test cases for setup redirect middleware."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_redirect_to_setup_when_not_configured(self, client):
|
||||||
async def test_redirect_to_setup_when_not_configured(self, client):
|
"""Test that HTML requests are redirected to /setup when not configured."""
|
||||||
"""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 = await client.get(
|
response = client.get(
|
||||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,40 +106,36 @@ class TestSetupRedirectMiddleware:
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.headers["location"] == "/setup"
|
assert response.headers["location"] == "/setup"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_setup_page_accessible_without_config(self, client):
|
||||||
async def test_setup_page_accessible_without_config(self, client):
|
"""Test that /setup page is accessible even when not configured."""
|
||||||
"""Test that /setup page is accessible when not configured."""
|
response = client.get("/setup")
|
||||||
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"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_api_returns_503_when_not_configured(self, client):
|
||||||
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 = await client.get("/api/data")
|
response = 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"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_exempt_api_endpoints_accessible(self, client):
|
||||||
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 = await client.get("/api/health")
|
response = 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 = await client.get("/api/auth/status")
|
response = 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
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_no_redirect_when_configured(self, client):
|
||||||
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")
|
||||||
@ -153,14 +147,13 @@ 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 = await client.get("/", headers={"Accept": "text/html"})
|
response = 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"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_api_works_when_configured(self, client):
|
||||||
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")
|
||||||
@ -172,44 +165,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 = await client.get("/api/data")
|
response = 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"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_static_files_always_accessible(self, client):
|
||||||
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 = await client.get("/static/css/style.css")
|
response = 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
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_redirect_when_only_auth_configured(self, client):
|
||||||
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 = await client.get("/", headers={"Accept": "text/html"})
|
response = 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]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_root_path_redirect(self, client):
|
||||||
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 = await client.get(
|
response = client.get(
|
||||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -217,8 +210,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):
|
def test_path_matching_exact_and_prefix(self, client):
|
||||||
"""Test that path matching works for both exact and prefix."""
|
"""Test that path matching works for both exact and prefix matches."""
|
||||||
middleware = SetupRedirectMiddleware(app=FastAPI())
|
middleware = SetupRedirectMiddleware(app=FastAPI())
|
||||||
|
|
||||||
# Exact matches
|
# Exact matches
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user