Compare commits

...

2 Commits

Author SHA1 Message Date
JSC
4bbae4c5d4 feat: Add endpoint to retrieve sounds with optional type filtering and implement corresponding repository method
Some checks failed
Backend CI / lint (push) Successful in 9m41s
Backend CI / test (push) Failing after 1m39s
2025-08-01 22:03:09 +02:00
JSC
d2d0240fdb feat: Add audio extraction endpoints and refactor sound API routes 2025-08-01 21:39:42 +02:00
10 changed files with 268 additions and 117 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

@@ -2,28 +2,21 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db, get_session_factory from app.core.database import get_db, get_session_factory
from app.core.dependencies import get_current_active_user_flexible from app.core.dependencies import get_current_active_user_flexible
from app.models.credit_action import CreditActionType from app.models.credit_action import CreditActionType
from app.models.sound import Sound
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 +35,27 @@ async def get_sound_repository(
return SoundRepository(session) return SoundRepository(session)
# EXTRACT @router.get("/")
@router.post("/extract") async def get_sounds(
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 current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)], sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
) -> ExtractionInfo: types: Annotated[
"""Get extraction information by ID.""" list[str] | None,
Query(description="Filter by sound types (e.g., SDB, TTS, EXT)"),
] = None,
) -> dict[str, list[Sound]]:
"""Get all sounds, optionally filtered by types."""
try: try:
extraction_info = await extraction_service.get_extraction_by_id(extraction_id) sounds = await sound_repo.get_by_types(types)
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: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extraction: {e!s}", detail=f"Failed to get sounds: {e!s}",
) from e ) from e
else: else:
return extraction_info return {"sounds": sounds}
@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

@@ -1,7 +1,7 @@
"""Sound repository for database operations.""" """Sound repository for database operations."""
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
@@ -95,3 +95,15 @@ class SoundRepository(BaseRepository[Sound]):
sound_type, sound_type,
) )
raise raise
async def get_by_types(self, sound_types: list[str] | None = None) -> list[Sound]:
"""Get sounds by types. If types is None or empty, return all sounds."""
try:
statement = select(Sound)
if sound_types:
statement = statement.where(col(Sound.type).in_(sound_types))
result = await self.session.exec(statement)
return list(result.all())
except Exception:
logger.exception("Failed to get sounds by types: %s", sound_types)
raise

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()
@@ -306,3 +306,94 @@ class TestSoundEndpoints:
data = response.json() data = response.json()
assert data["message"] == "All VLC instances stopped" assert data["message"] == "All VLC instances stopped"
assert data["stopped_count"] == 3 assert data["stopped_count"] == 3
@pytest.mark.asyncio
async def test_get_sounds_unauthenticated(self, client: AsyncClient) -> None:
"""Test getting sounds without authentication."""
response = await client.get("/api/v1/sounds/")
assert response.status_code == 401
data = response.json()
assert "Could not validate credentials" in data["detail"]
@pytest.mark.asyncio
async def test_get_sounds_authenticated(
self,
authenticated_client: AsyncClient,
authenticated_user: User,
) -> None:
"""Test getting sounds with authentication."""
from app.models.sound import Sound
with patch("app.repositories.sound.SoundRepository.get_by_types") as mock_get:
# Create mock sounds with all required fields
mock_sound_1 = Sound(
id=1,
name="Test Sound 1",
type="SDB",
filename="test1.mp3",
duration=5000,
size=1024,
hash="test_hash_1",
play_count=0,
is_normalized=False,
is_music=False,
is_deletable=True,
)
mock_sound_2 = Sound(
id=2,
name="Test Sound 2",
type="EXT",
filename="test2.mp3",
duration=7000,
size=2048,
hash="test_hash_2",
play_count=5,
is_normalized=False,
is_music=False,
is_deletable=True,
)
mock_get.return_value = [mock_sound_1, mock_sound_2]
response = await authenticated_client.get("/api/v1/sounds/")
assert response.status_code == 200
data = response.json()
assert "sounds" in data
assert len(data["sounds"]) == 2
@pytest.mark.asyncio
async def test_get_sounds_with_type_filter_authenticated(
self,
authenticated_client: AsyncClient,
authenticated_user: User,
) -> None:
"""Test getting sounds with type filtering."""
from app.models.sound import Sound
with patch("app.repositories.sound.SoundRepository.get_by_types") as mock_get:
# Create mock sound with all required fields
mock_sound = Sound(
id=1,
name="Test Sound 1",
type="SDB",
filename="test1.mp3",
duration=5000,
size=1024,
hash="test_hash_1",
play_count=0,
is_normalized=False,
is_music=False,
is_deletable=True,
)
mock_get.return_value = [mock_sound]
response = await authenticated_client.get("/api/v1/sounds/?types=SDB")
assert response.status_code == 200
data = response.json()
assert "sounds" in data
assert len(data["sounds"]) == 1
# Verify the repository was called with the correct types
mock_get.assert_called_once_with(["SDB"])