feat: Add audio extraction endpoints and refactor sound API routes

This commit is contained in:
JSC
2025-08-01 21:39:42 +02:00
parent 6068599a47
commit d2d0240fdb
9 changed files with 150 additions and 124 deletions

View File

@@ -2,13 +2,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import admin, auth, main, player, playlists, socket, sounds from app.api.v1 import admin, auth, extractions, main, player, playlists, socket, sounds
# V1 API router with v1 prefix # V1 API router with v1 prefix
api_router = APIRouter(prefix="/v1") api_router = APIRouter(prefix="/v1")
# Include all route modules # Include all route modules
api_router.include_router(auth.router, tags=["authentication"]) api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(extractions.router, tags=["extractions"])
api_router.include_router(main.router, tags=["main"]) api_router.include_router(main.router, tags=["main"])
api_router.include_router(player.router, tags=["player"]) api_router.include_router(player.router, tags=["player"])
api_router.include_router(playlists.router, tags=["playlists"]) api_router.include_router(playlists.router, tags=["playlists"])

View File

@@ -2,9 +2,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.admin import sounds from app.api.v1.admin import extractions, sounds
router = APIRouter(prefix="/admin") router = APIRouter(prefix="/admin")
# Include all admin sub-routers # Include all admin sub-routers
router.include_router(extractions.router)
router.include_router(sounds.router) router.include_router(sounds.router)

View File

@@ -0,0 +1,19 @@
"""Admin audio extraction API endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends
from app.core.dependencies import get_admin_user
from app.models.user import User
from app.services.extraction_processor import extraction_processor
router = APIRouter(prefix="/extractions", tags=["admin-extractions"])
@router.get("/status")
async def get_extraction_processor_status(
current_user: Annotated[User, Depends(get_admin_user)], # noqa: ARG001
) -> dict:
"""Get the status of the extraction processor. Admin only."""
return extraction_processor.get_status()

View File

@@ -8,7 +8,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_admin_user from app.core.dependencies import get_admin_user
from app.models.user import User from app.models.user import User
from app.services.extraction_processor import extraction_processor
from app.services.sound_normalizer import NormalizationResults, SoundNormalizerService from app.services.sound_normalizer import NormalizationResults, SoundNormalizerService
from app.services.sound_scanner import ScanResults, SoundScannerService from app.services.sound_scanner import ScanResults, SoundScannerService
@@ -229,10 +228,3 @@ async def normalize_sound_by_id(
) from e ) from e
# EXTRACTION PROCESSOR STATUS
@router.get("/extract/status")
async def get_extraction_processor_status(
current_user: Annotated[User, Depends(get_admin_user)], # noqa: ARG001
) -> dict:
"""Get the status of the extraction processor. Admin only."""
return extraction_processor.get_status()

113
app/api/v1/extractions.py Normal file
View File

@@ -0,0 +1,113 @@
"""Audio extraction API endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_active_user_flexible
from app.models.user import User
from app.services.extraction import ExtractionInfo, ExtractionService
from app.services.extraction_processor import extraction_processor
router = APIRouter(prefix="/extractions", tags=["extractions"])
async def get_extraction_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> ExtractionService:
"""Get the extraction service."""
return ExtractionService(session)
@router.post("/")
async def create_extraction(
url: str,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> dict[str, ExtractionInfo | str]:
"""Create a new extraction job for a URL."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
extraction_info = await extraction_service.create_extraction(
url,
current_user.id,
)
# Queue the extraction for background processing
await extraction_processor.queue_extraction(extraction_info["id"])
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create extraction: {e!s}",
) from e
else:
return {
"message": "Extraction queued successfully",
"extraction": extraction_info,
}
@router.get("/{extraction_id}")
async def get_extraction(
extraction_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> ExtractionInfo:
"""Get extraction information by ID."""
try:
extraction_info = await extraction_service.get_extraction_by_id(extraction_id)
if not extraction_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Extraction {extraction_id} not found",
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extraction: {e!s}",
) from e
else:
return extraction_info
@router.get("/")
async def get_user_extractions(
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> dict[str, list[ExtractionInfo]]:
"""Get all extractions for the current user."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
extractions = await extraction_service.get_user_extractions(current_user.id)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extractions: {e!s}",
) from e
else:
return {
"extractions": extractions,
}

View File

@@ -11,19 +11,11 @@ from app.models.credit_action import CreditActionType
from app.models.user import User from app.models.user import User
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.services.credit import CreditService, InsufficientCreditsError from app.services.credit import CreditService, InsufficientCreditsError
from app.services.extraction import ExtractionInfo, ExtractionService
from app.services.extraction_processor import extraction_processor
from app.services.vlc_player import VLCPlayerService, get_vlc_player_service from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
router = APIRouter(prefix="/sounds", tags=["sounds"]) router = APIRouter(prefix="/sounds", tags=["sounds"])
async def get_extraction_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> ExtractionService:
"""Get the extraction service."""
return ExtractionService(session)
def get_vlc_player() -> VLCPlayerService: def get_vlc_player() -> VLCPlayerService:
"""Get the VLC player service.""" """Get the VLC player service."""
@@ -42,98 +34,6 @@ async def get_sound_repository(
return SoundRepository(session) return SoundRepository(session)
# EXTRACT
@router.post("/extract")
async def create_extraction(
url: str,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> dict[str, ExtractionInfo | str]:
"""Create a new extraction job for a URL."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
extraction_info = await extraction_service.create_extraction(
url,
current_user.id,
)
# Queue the extraction for background processing
await extraction_processor.queue_extraction(extraction_info["id"])
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create extraction: {e!s}",
) from e
else:
return {
"message": "Extraction queued successfully",
"extraction": extraction_info,
}
@router.get("/extract/{extraction_id}")
async def get_extraction(
extraction_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> ExtractionInfo:
"""Get extraction information by ID."""
try:
extraction_info = await extraction_service.get_extraction_by_id(extraction_id)
if not extraction_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Extraction {extraction_id} not found",
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extraction: {e!s}",
) from e
else:
return extraction_info
@router.get("/extract")
async def get_user_extractions(
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
) -> dict[str, list[ExtractionInfo]]:
"""Get all extractions for the current user."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
extractions = await extraction_service.get_user_extractions(current_user.id)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extractions: {e!s}",
) from e
else:
return {
"extractions": extractions,
}
# VLC PLAYER # VLC PLAYER
@router.post("/play/{sound_id}") @router.post("/play/{sound_id}")

View File

@@ -513,7 +513,7 @@ class TestAdminSoundEndpoints:
mock_get_status.return_value = mock_status mock_get_status.return_value = mock_status
response = await authenticated_admin_client.get( response = await authenticated_admin_client.get(
"/api/v1/admin/sounds/extract/status", "/api/v1/admin/extractions/status",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -526,7 +526,7 @@ class TestAdminSoundEndpoints:
client: AsyncClient, client: AsyncClient,
) -> None: ) -> None:
"""Test getting extraction processor status without authentication.""" """Test getting extraction processor status without authentication."""
response = await client.get("/api/v1/admin/sounds/extract/status") response = await client.get("/api/v1/admin/extractions/status")
assert response.status_code == 401 assert response.status_code == 401
data = response.json() data = response.json()
@@ -556,7 +556,7 @@ class TestAdminSoundEndpoints:
base_url="http://test", base_url="http://test",
) as client: ) as client:
response = await client.get( response = await client.get(
"/api/v1/admin/sounds/extract/status", "/api/v1/admin/extractions/status",
headers=headers, headers=headers,
) )

View File

@@ -18,7 +18,7 @@ class TestExtractionEndpoints:
test_client.cookies.update(auth_cookies) test_client.cookies.update(auth_cookies)
response = await test_client.post( response = await test_client.post(
"/api/v1/sounds/extract", "/api/v1/extractions/",
params={"url": "https://www.youtube.com/watch?v=test"}, params={"url": "https://www.youtube.com/watch?v=test"},
) )
@@ -32,7 +32,7 @@ class TestExtractionEndpoints:
) -> None: ) -> None:
"""Test extraction creation without authentication.""" """Test extraction creation without authentication."""
response = await test_client.post( response = await test_client.post(
"/api/v1/sounds/extract", "/api/v1/extractions/",
params={"url": "https://www.youtube.com/watch?v=test"}, params={"url": "https://www.youtube.com/watch?v=test"},
) )
@@ -44,7 +44,7 @@ class TestExtractionEndpoints:
self, test_client: AsyncClient, self, test_client: AsyncClient,
) -> None: ) -> None:
"""Test extraction retrieval without authentication.""" """Test extraction retrieval without authentication."""
response = await test_client.get("/api/v1/sounds/extract/1") response = await test_client.get("/api/v1/extractions/1")
# Should return 401 for missing authentication # Should return 401 for missing authentication
assert response.status_code == 401 assert response.status_code == 401
@@ -60,7 +60,7 @@ class TestExtractionEndpoints:
test_client.cookies.update(admin_cookies) test_client.cookies.update(admin_cookies)
# The new admin endpoint should work # The new admin endpoint should work
response = await test_client.get("/api/v1/admin/sounds/extract/status") response = await test_client.get("/api/v1/admin/extractions/status")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "running" in data or "is_running" in data assert "running" in data or "is_running" in data
@@ -76,7 +76,7 @@ class TestExtractionEndpoints:
# Set cookies on client instance to avoid deprecation warning # Set cookies on client instance to avoid deprecation warning
test_client.cookies.update(auth_cookies) test_client.cookies.update(auth_cookies)
response = await test_client.get("/api/v1/sounds/extract") response = await test_client.get("/api/v1/extractions/")
# Should succeed and return empty list (no extractions in test DB) # Should succeed and return empty list (no extractions in test DB)
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -45,7 +45,7 @@ class TestSoundEndpoints:
mock_queue.return_value = None mock_queue.return_value = None
response = await authenticated_client.post( response = await authenticated_client.post(
"/api/v1/sounds/extract", "/api/v1/extractions/",
params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
) )
@@ -62,7 +62,7 @@ class TestSoundEndpoints:
async def test_create_extraction_unauthenticated(self, client: AsyncClient) -> None: async def test_create_extraction_unauthenticated(self, client: AsyncClient) -> None:
"""Test extraction creation without authentication.""" """Test extraction creation without authentication."""
response = await client.post( response = await client.post(
"/api/v1/sounds/extract", "/api/v1/extractions/",
params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
) )
@@ -83,7 +83,7 @@ class TestSoundEndpoints:
mock_create.side_effect = ValueError("Invalid URL") mock_create.side_effect = ValueError("Invalid URL")
response = await authenticated_client.post( response = await authenticated_client.post(
"/api/v1/sounds/extract", "/api/v1/extractions/",
params={"url": "invalid-url"}, params={"url": "invalid-url"},
) )
@@ -114,7 +114,7 @@ class TestSoundEndpoints:
) as mock_get: ) as mock_get:
mock_get.return_value = mock_extraction_info mock_get.return_value = mock_extraction_info
response = await authenticated_client.get("/api/v1/sounds/extract/1") response = await authenticated_client.get("/api/v1/extractions/1")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -135,7 +135,7 @@ class TestSoundEndpoints:
) as mock_get: ) as mock_get:
mock_get.return_value = None mock_get.return_value = None
response = await authenticated_client.get("/api/v1/sounds/extract/999") response = await authenticated_client.get("/api/v1/extractions/999")
assert response.status_code == 404 assert response.status_code == 404
data = response.json() data = response.json()
@@ -176,7 +176,7 @@ class TestSoundEndpoints:
) as mock_get: ) as mock_get:
mock_get.return_value = mock_extractions mock_get.return_value = mock_extractions
response = await authenticated_client.get("/api/v1/sounds/extract") response = await authenticated_client.get("/api/v1/extractions/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()