Refactor sound and extraction services to include user and timestamp fields
All checks were successful
Backend CI / lint (push) Successful in 18m8s
Backend CI / test (push) Successful in 53m35s

- Updated ExtractionInfo to include user_id, created_at, and updated_at fields.
- Modified ExtractionService to return user and timestamp information in extraction responses.
- Enhanced sound serialization in PlayerState to include extraction URL if available.
- Adjusted PlaylistRepository to load sound extractions when retrieving playlist sounds.
- Added tests for new fields in extraction and sound endpoints, ensuring proper response structure.
- Created new test file endpoints for sound downloads and thumbnail retrievals, including success and error cases.
- Refactored various test cases for consistency and clarity, ensuring proper mocking and assertions.
This commit is contained in:
JSC
2025-08-03 20:54:14 +02:00
parent 77446cb5a8
commit b4f0f54516
20 changed files with 780 additions and 73 deletions

View File

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

View File

@@ -226,5 +226,3 @@ async def normalize_sound_by_id(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to normalize sound: {e!s}",
) from e

153
app/api/v1/files.py Normal file
View File

@@ -0,0 +1,153 @@
"""File serving API endpoints for audio files and thumbnails."""
import mimetypes
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import get_current_active_user_flexible
from app.core.logging import get_logger
from app.models.user import User
from app.repositories.sound import SoundRepository
from app.utils.audio import get_sound_file_path
logger = get_logger(__name__)
router = APIRouter(prefix="/files", tags=["files"])
async def get_sound_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SoundRepository:
"""Get the sound repository."""
return SoundRepository(session)
@router.get("/sounds/{sound_id}/download")
async def download_sound(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
) -> FileResponse:
"""Download a sound file."""
try:
# Get the sound record
sound = await sound_repo.get_by_id(sound_id)
if not sound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sound with ID {sound_id} not found",
)
# Get the file path using the audio utility
file_path = get_sound_file_path(sound)
# Determine filename based on normalization status
if sound.is_normalized and sound.normalized_filename:
filename = sound.normalized_filename
else:
filename = sound.filename
# Check if file exists
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Sound file not found on disk",
)
# Get MIME type
mime_type, _ = mimetypes.guess_type(str(file_path))
if not mime_type:
mime_type = "audio/mpeg" # Default to MP3
logger.info(
"Serving sound download: %s (user: %d, sound: %d)",
filename,
current_user.id,
sound_id,
)
return FileResponse(
path=str(file_path),
filename=filename,
media_type=mime_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except HTTPException:
raise
except Exception as e:
logger.exception("Error serving sound download for sound %d", sound_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to serve sound file",
) from e
@router.get("/sounds/{sound_id}/thumbnail")
async def get_sound_thumbnail(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
) -> FileResponse:
"""Get a sound's thumbnail image."""
try:
# Get the sound record
sound = await sound_repo.get_by_id(sound_id)
if not sound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sound with ID {sound_id} not found",
)
# Check if sound has a thumbnail
if not sound.thumbnail:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No thumbnail available for this sound",
)
# Get thumbnail file path
thumbnail_path = Path(settings.EXTRACTION_THUMBNAILS_DIR) / sound.thumbnail
# Check if thumbnail file exists
if not thumbnail_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail file not found on disk",
)
# Get MIME type
mime_type, _ = mimetypes.guess_type(str(thumbnail_path))
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG
logger.debug(
"Serving thumbnail: %s (user: %d, sound: %d)",
sound.thumbnail,
current_user.id,
sound_id,
)
return FileResponse(
path=str(thumbnail_path),
media_type=mime_type,
headers={
"Cache-Control": "public, max-age=3600", # Cache for 1 hour
"Content-Disposition": "inline", # Display inline, not download
},
)
except HTTPException:
raise
except Exception as e:
logger.exception("Error serving thumbnail for sound %d", sound_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to serve thumbnail",
) from e

View File

@@ -17,7 +17,6 @@ from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
router = APIRouter(prefix="/sounds", tags=["sounds"])
def get_vlc_player() -> VLCPlayerService:
"""Get the VLC player service."""
return get_vlc_player_service(get_session_factory())
@@ -56,7 +55,6 @@ async def get_sounds(
return {"sounds": sounds}
# VLC PLAYER
@router.post("/play/{sound_id}")
async def play_sound_with_vlc(

View File

@@ -1,6 +1,7 @@
"""Playlist repository for database operations."""
from sqlalchemy import func
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -84,11 +85,12 @@ class PlaylistRepository(BaseRepository[Playlist]):
raise
async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]:
"""Get all sounds in a playlist, ordered by position."""
"""Get all sounds in a playlist with extractions, ordered by position."""
try:
statement = (
select(Sound)
.join(PlaylistSound)
.options(selectinload(Sound.extractions))
.where(PlaylistSound.playlist_id == playlist_id)
.order_by(PlaylistSound.position)
)

View File

@@ -31,6 +31,9 @@ class ExtractionInfo(TypedDict):
status: str
error: str | None
sound_id: int | None
user_id: int
created_at: str
updated_at: str
class ExtractionService:
@@ -88,6 +91,9 @@ class ExtractionService:
"status": extraction.status,
"error": extraction.error,
"sound_id": extraction.sound_id,
"user_id": extraction.user_id,
"created_at": extraction.created_at.isoformat(),
"updated_at": extraction.updated_at.isoformat(),
}
async def _detect_service_info(self, url: str) -> dict[str, str | None] | None:
@@ -201,6 +207,7 @@ class ExtractionService:
# Create Sound record
sound = await self._create_sound_record(
final_audio_path,
final_thumbnail_path,
extraction_title,
extraction_service,
extraction_service_id,
@@ -208,6 +215,9 @@ class ExtractionService:
# Store sound_id early to avoid session detachment issues
sound_id = sound.id
if not sound_id:
msg = "Sound creation failed - no ID returned"
raise RuntimeError(msg)
# Normalize the sound
await self._normalize_sound(sound_id)
@@ -234,6 +244,8 @@ class ExtractionService:
error_msg,
)
else:
# Get updated extraction to get latest timestamps
updated_extraction = await self.extraction_repo.get_by_id(extraction_id)
return {
"id": extraction_id,
"url": extraction_url,
@@ -243,6 +255,17 @@ class ExtractionService:
"status": "completed",
"error": None,
"sound_id": sound_id,
"user_id": user_id,
"created_at": (
updated_extraction.created_at.isoformat()
if updated_extraction
else ""
),
"updated_at": (
updated_extraction.updated_at.isoformat()
if updated_extraction
else ""
),
}
# Update extraction with error
@@ -254,6 +277,8 @@ class ExtractionService:
},
)
# Get updated extraction to get latest timestamps
updated_extraction = await self.extraction_repo.get_by_id(extraction_id)
return {
"id": extraction_id,
"url": extraction_url,
@@ -263,6 +288,17 @@ class ExtractionService:
"status": "failed",
"error": error_msg,
"sound_id": None,
"user_id": user_id,
"created_at": (
updated_extraction.created_at.isoformat()
if updated_extraction
else ""
),
"updated_at": (
updated_extraction.updated_at.isoformat()
if updated_extraction
else ""
),
}
async def _extract_media(
@@ -409,6 +445,7 @@ class ExtractionService:
async def _create_sound_record(
self,
audio_path: Path,
thumbnail_path: Path | None,
title: str | None,
service: str | None,
service_id: str | None,
@@ -427,6 +464,7 @@ class ExtractionService:
"duration": duration,
"size": size,
"hash": file_hash,
"thumbnail": thumbnail_path.name if thumbnail_path else None,
"is_deletable": True, # Extracted sounds can be deleted
"is_music": True, # Assume extracted content is music
"is_normalized": False,
@@ -434,7 +472,11 @@ class ExtractionService:
}
sound = await self.sound_repo.create(sound_data)
logger.info("Created sound record with ID: %d", sound.id)
logger.info(
"Created sound record with ID: %d, thumbnail: %s",
sound.id,
thumbnail_path.name if thumbnail_path else "None",
)
return sound
@@ -496,6 +538,9 @@ class ExtractionService:
"status": extraction.status,
"error": extraction.error,
"sound_id": extraction.sound_id,
"user_id": extraction.user_id,
"created_at": extraction.created_at.isoformat(),
"updated_at": extraction.updated_at.isoformat(),
}
async def get_user_extractions(self, user_id: int) -> list[ExtractionInfo]:
@@ -513,6 +558,9 @@ class ExtractionService:
"status": extraction.status,
"error": extraction.error,
"sound_id": extraction.sound_id,
"user_id": extraction.user_id,
"created_at": extraction.created_at.isoformat(),
"updated_at": extraction.updated_at.isoformat(),
}
for extraction in extractions
]
@@ -532,6 +580,9 @@ class ExtractionService:
"status": extraction.status,
"error": extraction.error,
"sound_id": extraction.sound_id,
"user_id": extraction.user_id,
"created_at": extraction.created_at.isoformat(),
"updated_at": extraction.updated_at.isoformat(),
}
for extraction in extractions
]

View File

@@ -87,6 +87,14 @@ class PlayerState:
"""Serialize a sound object for JSON serialization."""
if not sound:
return None
# Get extraction URL if sound is linked to an extraction
extract_url = None
if hasattr(sound, "extractions") and sound.extractions:
# Get the first extraction (there should only be one per sound)
extraction = sound.extractions[0]
extract_url = extraction.url
return {
"id": sound.id,
"name": sound.name,
@@ -96,6 +104,7 @@ class PlayerState:
"type": sound.type,
"thumbnail": sound.thumbnail,
"play_count": sound.play_count,
"extract_url": extract_url,
}
@@ -585,7 +594,8 @@ class PlayerService:
# Check if track finished
player_state = self._player.get_state()
if hasattr(vlc, "State") and player_state == vlc.State.Ended:
vlc_state_ended = 6 # vlc.State.Ended value
if player_state == vlc_state_ended:
# Track finished, handle auto-advance
self._schedule_async_task(self._handle_track_finished())