feat: add SoundRepository and SoundScannerService for audio file management
- Implemented SoundRepository for database operations related to sounds, including methods for retrieving, creating, updating, and deleting sound records. - Developed SoundScannerService to scan directories for audio files, calculate their metadata, and synchronize with the database. - Added support for various audio file formats and integrated ffmpeg for audio duration extraction. - Created comprehensive tests for sound API endpoints and sound scanner service to ensure functionality and error handling. - Updated dependencies to include ffmpeg-python for audio processing.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, main, socket
|
||||
from app.api.v1 import auth, main, socket, sounds
|
||||
|
||||
# V1 API router with v1 prefix
|
||||
api_router = APIRouter(prefix="/v1")
|
||||
@@ -11,3 +11,4 @@ api_router = APIRouter(prefix="/v1")
|
||||
api_router.include_router(main.router, tags=["main"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||
api_router.include_router(socket.router, tags=["socket"])
|
||||
api_router.include_router(sounds.router, tags=["sounds"])
|
||||
|
||||
79
app/api/v1/sounds.py
Normal file
79
app/api/v1/sounds.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Sound management 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.sound_scanner import ScanResults, SoundScannerService
|
||||
|
||||
router = APIRouter(prefix="/sounds", tags=["sounds"])
|
||||
|
||||
|
||||
async def get_sound_scanner_service(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> SoundScannerService:
|
||||
"""Get the sound scanner service."""
|
||||
return SoundScannerService(session)
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan_sounds(
|
||||
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||
scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)],
|
||||
) -> dict[str, ScanResults | str]:
|
||||
"""Sync the soundboard directory (add/update/delete sounds)."""
|
||||
# Only allow admins to scan sounds
|
||||
if current_user.role not in ["admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only administrators can sync sounds",
|
||||
)
|
||||
|
||||
try:
|
||||
results = await scanner_service.scan_soundboard_directory()
|
||||
return {
|
||||
"message": "Sound sync completed",
|
||||
"results": results,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to sync sounds: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/scan/custom")
|
||||
async def scan_custom_directory(
|
||||
directory: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||
scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)],
|
||||
sound_type: str = "SDB",
|
||||
) -> dict[str, ScanResults | str]:
|
||||
"""Sync a custom directory with the database (add/update/delete sounds)."""
|
||||
# Only allow admins to sync sounds
|
||||
if current_user.role not in ["admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only administrators can sync sounds",
|
||||
)
|
||||
|
||||
try:
|
||||
results = await scanner_service.scan_directory(directory, sound_type)
|
||||
return {
|
||||
"message": f"Sync of directory '{directory}' completed",
|
||||
"results": results,
|
||||
}
|
||||
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 sync directory: {e!s}",
|
||||
) from e
|
||||
128
app/repositories/sound.py
Normal file
128
app/repositories/sound.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Sound repository for database operations."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.sound import Sound
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SoundRepository:
|
||||
"""Repository for sound operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""Initialize the sound repository."""
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, sound_id: int) -> Sound | None:
|
||||
"""Get a sound by ID."""
|
||||
try:
|
||||
statement = select(Sound).where(Sound.id == sound_id)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get sound by ID: %s", sound_id)
|
||||
raise
|
||||
|
||||
async def get_by_filename(self, filename: str) -> Sound | None:
|
||||
"""Get a sound by filename."""
|
||||
try:
|
||||
statement = select(Sound).where(Sound.filename == filename)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get sound by filename: %s", filename)
|
||||
raise
|
||||
|
||||
async def get_by_hash(self, hash_value: str) -> Sound | None:
|
||||
"""Get a sound by hash."""
|
||||
try:
|
||||
statement = select(Sound).where(Sound.hash == hash_value)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get sound by hash")
|
||||
raise
|
||||
|
||||
async def get_by_type(self, sound_type: str) -> list[Sound]:
|
||||
"""Get all sounds by type."""
|
||||
try:
|
||||
statement = select(Sound).where(Sound.type == sound_type)
|
||||
result = await self.session.exec(statement)
|
||||
return list(result.all())
|
||||
except Exception:
|
||||
logger.exception("Failed to get sounds by type: %s", sound_type)
|
||||
raise
|
||||
|
||||
async def create(self, sound_data: dict[str, Any]) -> Sound:
|
||||
"""Create a new sound."""
|
||||
try:
|
||||
sound = Sound(**sound_data)
|
||||
self.session.add(sound)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(sound)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to create sound")
|
||||
raise
|
||||
else:
|
||||
logger.info("Created new sound: %s", sound.name)
|
||||
return sound
|
||||
|
||||
async def update(self, sound: Sound, update_data: dict[str, Any]) -> Sound:
|
||||
"""Update a sound."""
|
||||
try:
|
||||
for field, value in update_data.items():
|
||||
setattr(sound, field, value)
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(sound)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to update sound")
|
||||
raise
|
||||
else:
|
||||
logger.info("Updated sound: %s", sound.name)
|
||||
return sound
|
||||
|
||||
async def delete(self, sound: Sound) -> None:
|
||||
"""Delete a sound."""
|
||||
try:
|
||||
await self.session.delete(sound)
|
||||
await self.session.commit()
|
||||
logger.info("Deleted sound: %s", sound.name)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to delete sound")
|
||||
raise
|
||||
|
||||
async def search_by_name(self, query: str) -> list[Sound]:
|
||||
"""Search sounds by name (case-insensitive)."""
|
||||
try:
|
||||
statement = select(Sound).where(
|
||||
func.lower(Sound.name).like(f"%{query.lower()}%"),
|
||||
)
|
||||
result = await self.session.exec(statement)
|
||||
return list(result.all())
|
||||
except Exception:
|
||||
logger.exception("Failed to search sounds by name: %s", query)
|
||||
raise
|
||||
|
||||
async def get_popular_sounds(self, limit: int = 10) -> list[Sound]:
|
||||
"""Get the most played sounds."""
|
||||
try:
|
||||
statement = (
|
||||
select(Sound)
|
||||
.order_by(desc(Sound.play_count))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.exec(statement)
|
||||
return list(result.all())
|
||||
except Exception:
|
||||
logger.exception("Failed to get popular sounds")
|
||||
raise
|
||||
274
app/services/sound_scanner.py
Normal file
274
app/services/sound_scanner.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Sound scanner service for scanning and importing audio files."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import ffmpeg # type: ignore[import-untyped]
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.sound import Sound
|
||||
from app.repositories.sound import SoundRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileInfo(TypedDict):
|
||||
"""Type definition for file information in scan results."""
|
||||
filename: str
|
||||
status: str
|
||||
reason: str | None
|
||||
name: str | None
|
||||
duration: int | None
|
||||
size: int | None
|
||||
id: int | None
|
||||
error: str | None
|
||||
changes: list[str] | None
|
||||
|
||||
|
||||
class ScanResults(TypedDict):
|
||||
"""Type definition for scan results."""
|
||||
scanned: int
|
||||
added: int
|
||||
updated: int
|
||||
deleted: int
|
||||
skipped: int
|
||||
errors: int
|
||||
files: list[FileInfo]
|
||||
|
||||
|
||||
class SoundScannerService:
|
||||
"""Service for scanning and importing audio files."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""Initialize the sound scanner service."""
|
||||
self.session = session
|
||||
self.sound_repo = SoundRepository(session)
|
||||
self.supported_extensions = {".mp3", ".wav", ".opus", ".flac", ".ogg", ".m4a", ".aac"}
|
||||
|
||||
def get_file_hash(self, file_path: Path) -> str:
|
||||
"""Calculate MD5 hash of a file."""
|
||||
hash_md5 = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()
|
||||
|
||||
def get_audio_duration(self, file_path: Path) -> int:
|
||||
"""Get audio duration in milliseconds using ffmpeg."""
|
||||
try:
|
||||
probe = ffmpeg.probe(str(file_path))
|
||||
duration = float(probe["format"]["duration"])
|
||||
return int(duration * 1000) # Convert to milliseconds
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get duration for %s: %s", file_path, e)
|
||||
return 0
|
||||
|
||||
def get_file_size(self, file_path: Path) -> int:
|
||||
"""Get file size in bytes."""
|
||||
return file_path.stat().st_size
|
||||
|
||||
def extract_name_from_filename(self, filename: str) -> str:
|
||||
"""Extract a clean name from filename."""
|
||||
# Remove extension
|
||||
name = Path(filename).stem
|
||||
# Replace underscores and hyphens with spaces
|
||||
name = name.replace("_", " ").replace("-", " ")
|
||||
# Capitalize words
|
||||
name = " ".join(word.capitalize() for word in name.split())
|
||||
return name
|
||||
|
||||
async def scan_directory(
|
||||
self,
|
||||
directory_path: str,
|
||||
sound_type: str = "SDB",
|
||||
) -> ScanResults:
|
||||
"""Sync a directory with the database (add/update/delete sounds)."""
|
||||
scan_path = Path(directory_path)
|
||||
|
||||
if not scan_path.exists():
|
||||
msg = f"Directory does not exist: {directory_path}"
|
||||
raise ValueError(msg)
|
||||
|
||||
if not scan_path.is_dir():
|
||||
msg = f"Path is not a directory: {directory_path}"
|
||||
raise ValueError(msg)
|
||||
|
||||
results: ScanResults = {
|
||||
"scanned": 0,
|
||||
"added": 0,
|
||||
"updated": 0,
|
||||
"deleted": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
logger.info("Starting sync of directory: %s", directory_path)
|
||||
|
||||
# Get all existing sounds of this type from database
|
||||
existing_sounds = await self.sound_repo.get_by_type(sound_type)
|
||||
sounds_by_filename = {sound.filename: sound for sound in existing_sounds}
|
||||
|
||||
# Get all audio files from directory
|
||||
audio_files = [
|
||||
f for f in scan_path.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in self.supported_extensions
|
||||
]
|
||||
|
||||
# Process each file in directory
|
||||
processed_filenames = set()
|
||||
for file_path in audio_files:
|
||||
results["scanned"] += 1
|
||||
filename = file_path.name
|
||||
processed_filenames.add(filename)
|
||||
|
||||
try:
|
||||
await self._sync_audio_file(
|
||||
file_path,
|
||||
sound_type,
|
||||
sounds_by_filename.get(filename),
|
||||
results,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error processing file %s", file_path)
|
||||
results["errors"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "error",
|
||||
"reason": None,
|
||||
"name": None,
|
||||
"duration": None,
|
||||
"size": None,
|
||||
"id": None,
|
||||
"error": str(e),
|
||||
"changes": None,
|
||||
})
|
||||
|
||||
# Delete sounds that no longer exist in directory
|
||||
for filename, sound in sounds_by_filename.items():
|
||||
if filename not in processed_filenames:
|
||||
try:
|
||||
await self.sound_repo.delete(sound)
|
||||
logger.info("Deleted sound no longer in directory: %s", filename)
|
||||
results["deleted"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "deleted",
|
||||
"reason": "file no longer exists",
|
||||
"name": sound.name,
|
||||
"duration": sound.duration,
|
||||
"size": sound.size,
|
||||
"id": sound.id,
|
||||
"error": None,
|
||||
"changes": None,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error deleting sound %s", filename)
|
||||
results["errors"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "error",
|
||||
"reason": "failed to delete",
|
||||
"name": sound.name,
|
||||
"duration": sound.duration,
|
||||
"size": sound.size,
|
||||
"id": sound.id,
|
||||
"error": str(e),
|
||||
"changes": None,
|
||||
})
|
||||
|
||||
logger.info("Sync completed: %s", results)
|
||||
return results
|
||||
|
||||
async def _sync_audio_file(
|
||||
self,
|
||||
file_path: Path,
|
||||
sound_type: str,
|
||||
existing_sound: Sound | None,
|
||||
results: ScanResults,
|
||||
) -> None:
|
||||
"""Sync a single audio file (add new or update existing)."""
|
||||
filename = file_path.name
|
||||
file_hash = self.get_file_hash(file_path)
|
||||
duration = self.get_audio_duration(file_path)
|
||||
size = self.get_file_size(file_path)
|
||||
name = self.extract_name_from_filename(filename)
|
||||
|
||||
if existing_sound is None:
|
||||
# Add new sound
|
||||
sound_data = {
|
||||
"type": sound_type,
|
||||
"name": name,
|
||||
"filename": filename,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
"hash": file_hash,
|
||||
"is_deletable": False,
|
||||
"is_music": False,
|
||||
"is_normalized": False,
|
||||
"play_count": 0,
|
||||
}
|
||||
|
||||
sound = await self.sound_repo.create(sound_data)
|
||||
logger.info("Added new sound: %s (ID: %s)", sound.name, sound.id)
|
||||
|
||||
results["added"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "added",
|
||||
"reason": None,
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
"id": sound.id,
|
||||
"error": None,
|
||||
"changes": None,
|
||||
})
|
||||
|
||||
elif existing_sound.hash != file_hash:
|
||||
# Update existing sound (file was modified)
|
||||
update_data = {
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
"hash": file_hash,
|
||||
}
|
||||
|
||||
await self.sound_repo.update(existing_sound, update_data)
|
||||
logger.info("Updated modified sound: %s (ID: %s)", name, existing_sound.id)
|
||||
|
||||
results["updated"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "updated",
|
||||
"reason": "file was modified",
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
"id": existing_sound.id,
|
||||
"error": None,
|
||||
"changes": ["hash", "duration", "size", "name"],
|
||||
})
|
||||
|
||||
else:
|
||||
# File unchanged, skip
|
||||
logger.debug("Sound unchanged: %s", filename)
|
||||
results["skipped"] += 1
|
||||
results["files"].append({
|
||||
"filename": filename,
|
||||
"status": "skipped",
|
||||
"reason": "file unchanged",
|
||||
"name": existing_sound.name,
|
||||
"duration": existing_sound.duration,
|
||||
"size": existing_sound.size,
|
||||
"id": existing_sound.id,
|
||||
"error": None,
|
||||
"changes": None,
|
||||
})
|
||||
|
||||
async def scan_soundboard_directory(self) -> ScanResults:
|
||||
"""Sync the default soundboard directory."""
|
||||
soundboard_path = "sounds/originals/soundboard"
|
||||
return await self.scan_directory(soundboard_path, "SDB")
|
||||
Reference in New Issue
Block a user