feat: Implement sound normalization service and API endpoints
- Added SoundNormalizerService for normalizing audio files with support for one-pass and two-pass normalization methods. - Introduced API endpoints for normalizing all sounds and specific sounds by ID, including support for force normalization and handling of already normalized sounds. - Created comprehensive test suite for the sound normalizer service and its API endpoints, covering various scenarios including success, errors, and edge cases. - Refactored sound scanning service to utilize SHA-256 for file hashing instead of MD5 for improved security. - Enhanced logging and error handling throughout the sound normalization process.
This commit is contained in:
@@ -16,6 +16,7 @@ logger = get_logger(__name__)
|
||||
|
||||
class FileInfo(TypedDict):
|
||||
"""Type definition for file information in scan results."""
|
||||
|
||||
filename: str
|
||||
status: str
|
||||
reason: str | None
|
||||
@@ -29,6 +30,7 @@ class FileInfo(TypedDict):
|
||||
|
||||
class ScanResults(TypedDict):
|
||||
"""Type definition for scan results."""
|
||||
|
||||
scanned: int
|
||||
added: int
|
||||
updated: int
|
||||
@@ -45,15 +47,23 @@ class SoundScannerService:
|
||||
"""Initialize the sound scanner service."""
|
||||
self.session = session
|
||||
self.sound_repo = SoundRepository(session)
|
||||
self.supported_extensions = {".mp3", ".wav", ".opus", ".flac", ".ogg", ".m4a", ".aac"}
|
||||
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()
|
||||
"""Calculate SHA-256 hash of a file."""
|
||||
hash_sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()
|
||||
hash_sha256.update(chunk)
|
||||
return hash_sha256.hexdigest()
|
||||
|
||||
def get_audio_duration(self, file_path: Path) -> int:
|
||||
"""Get audio duration in milliseconds using ffmpeg."""
|
||||
@@ -76,8 +86,7 @@ class SoundScannerService:
|
||||
# Replace underscores and hyphens with spaces
|
||||
name = name.replace("_", " ").replace("-", " ")
|
||||
# Capitalize words
|
||||
name = " ".join(word.capitalize() for word in name.split())
|
||||
return name
|
||||
return " ".join(word.capitalize() for word in name.split())
|
||||
|
||||
async def scan_directory(
|
||||
self,
|
||||
@@ -113,7 +122,8 @@ class SoundScannerService:
|
||||
|
||||
# Get all audio files from directory
|
||||
audio_files = [
|
||||
f for f in scan_path.iterdir()
|
||||
f
|
||||
for f in scan_path.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in self.supported_extensions
|
||||
]
|
||||
|
||||
@@ -134,17 +144,19 @@ class SoundScannerService:
|
||||
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,
|
||||
})
|
||||
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():
|
||||
@@ -153,31 +165,35 @@ class SoundScannerService:
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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
|
||||
@@ -215,17 +231,19 @@ class SoundScannerService:
|
||||
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,
|
||||
})
|
||||
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)
|
||||
@@ -240,33 +258,37 @@ class SoundScannerService:
|
||||
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"],
|
||||
})
|
||||
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,
|
||||
})
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user