feat: Refactor audio utility functions and update sound services to use shared methods
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
"""Sound normalizer service for normalizing audio files using ffmpeg loudnorm."""
|
"""Sound normalizer service for normalizing audio files using ffmpeg loudnorm."""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -14,6 +13,7 @@ from app.core.config import settings
|
|||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -107,27 +107,6 @@ class SoundNormalizerService:
|
|||||||
original_dir = type_to_original_dir.get(sound_type, "sounds/originals/other")
|
original_dir = type_to_original_dir.get(sound_type, "sounds/originals/other")
|
||||||
return Path(original_dir) / filename
|
return Path(original_dir) / filename
|
||||||
|
|
||||||
def _get_file_hash(self, file_path: Path) -> str:
|
|
||||||
"""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_sha256.update(chunk)
|
|
||||||
return hash_sha256.hexdigest()
|
|
||||||
|
|
||||||
def _get_file_size(self, file_path: Path) -> int:
|
|
||||||
"""Get file size in bytes."""
|
|
||||||
return file_path.stat().st_size
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
async def _normalize_audio_one_pass(
|
async def _normalize_audio_one_pass(
|
||||||
self,
|
self,
|
||||||
@@ -341,9 +320,9 @@ class SoundNormalizerService:
|
|||||||
await self._normalize_audio_two_pass(original_path, normalized_path)
|
await self._normalize_audio_two_pass(original_path, normalized_path)
|
||||||
|
|
||||||
# Get normalized file info
|
# Get normalized file info
|
||||||
normalized_duration = self._get_audio_duration(normalized_path)
|
normalized_duration = get_audio_duration(normalized_path)
|
||||||
normalized_size = self._get_file_size(normalized_path)
|
normalized_size = get_file_size(normalized_path)
|
||||||
normalized_hash = self._get_file_hash(normalized_path)
|
normalized_hash = get_file_hash(normalized_path)
|
||||||
normalized_filename = normalized_path.name
|
normalized_filename = normalized_path.name
|
||||||
|
|
||||||
# Update sound in database
|
# Update sound in database
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"""Sound scanner service for scanning and importing audio files."""
|
"""Sound scanner service for scanning and importing audio files."""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
import ffmpeg # type: ignore[import-untyped]
|
|
||||||
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
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -57,27 +56,6 @@ class SoundScannerService:
|
|||||||
".aac",
|
".aac",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_file_hash(self, file_path: Path) -> str:
|
|
||||||
"""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_sha256.update(chunk)
|
|
||||||
return hash_sha256.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:
|
def extract_name_from_filename(self, filename: str) -> str:
|
||||||
"""Extract a clean name from filename."""
|
"""Extract a clean name from filename."""
|
||||||
@@ -207,9 +185,9 @@ class SoundScannerService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Sync a single audio file (add new or update existing)."""
|
"""Sync a single audio file (add new or update existing)."""
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
file_hash = self.get_file_hash(file_path)
|
file_hash = get_file_hash(file_path)
|
||||||
duration = self.get_audio_duration(file_path)
|
duration = get_audio_duration(file_path)
|
||||||
size = self.get_file_size(file_path)
|
size = get_file_size(file_path)
|
||||||
name = self.extract_name_from_filename(filename)
|
name = self.extract_name_from_filename(filename)
|
||||||
|
|
||||||
if existing_sound is None:
|
if existing_sound is None:
|
||||||
|
|||||||
35
app/utils/audio.py
Normal file
35
app/utils/audio.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Audio file utility functions shared across audio processing services."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ffmpeg # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_hash(file_path: Path) -> str:
|
||||||
|
"""Calculate SHA-256 hash of a file."""
|
||||||
|
hash_sha256 = hashlib.sha256()
|
||||||
|
with file_path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
hash_sha256.update(chunk)
|
||||||
|
return hash_sha256.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_size(file_path: Path) -> int:
|
||||||
|
"""Get file size in bytes."""
|
||||||
|
return file_path.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_duration(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
|
||||||
@@ -82,7 +82,8 @@ class TestSoundNormalizerService:
|
|||||||
temp_path = Path(f.name)
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hash_value = normalizer_service._get_file_hash(temp_path)
|
from app.utils.audio import get_file_hash
|
||||||
|
hash_value = get_file_hash(temp_path)
|
||||||
assert len(hash_value) == 64 # SHA-256 hash length
|
assert len(hash_value) == 64 # SHA-256 hash length
|
||||||
assert isinstance(hash_value, str)
|
assert isinstance(hash_value, str)
|
||||||
finally:
|
finally:
|
||||||
@@ -96,30 +97,33 @@ class TestSoundNormalizerService:
|
|||||||
temp_path = Path(f.name)
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
size = normalizer_service._get_file_size(temp_path)
|
from app.utils.audio import get_file_size
|
||||||
|
size = get_file_size(temp_path)
|
||||||
assert size > 0
|
assert size > 0
|
||||||
assert isinstance(size, int)
|
assert isinstance(size, int)
|
||||||
finally:
|
finally:
|
||||||
temp_path.unlink()
|
temp_path.unlink()
|
||||||
|
|
||||||
@patch("app.services.sound_normalizer.ffmpeg.probe")
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
def test_get_audio_duration_success(self, mock_probe, normalizer_service):
|
def test_get_audio_duration_success(self, mock_probe, normalizer_service):
|
||||||
"""Test successful audio duration extraction."""
|
"""Test successful audio duration extraction."""
|
||||||
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
||||||
|
|
||||||
temp_path = Path("/fake/path/test.mp3")
|
temp_path = Path("/fake/path/test.mp3")
|
||||||
duration = normalizer_service._get_audio_duration(temp_path)
|
from app.utils.audio import get_audio_duration
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
||||||
mock_probe.assert_called_once_with(str(temp_path))
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
@patch("app.services.sound_normalizer.ffmpeg.probe")
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
def test_get_audio_duration_failure(self, mock_probe, normalizer_service):
|
def test_get_audio_duration_failure(self, mock_probe, normalizer_service):
|
||||||
"""Test audio duration extraction failure."""
|
"""Test audio duration extraction failure."""
|
||||||
mock_probe.side_effect = Exception("FFmpeg error")
|
mock_probe.side_effect = Exception("FFmpeg error")
|
||||||
|
|
||||||
temp_path = Path("/fake/path/test.mp3")
|
temp_path = Path("/fake/path/test.mp3")
|
||||||
duration = normalizer_service._get_audio_duration(temp_path)
|
from app.utils.audio import get_audio_duration
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
assert duration == 0
|
assert duration == 0
|
||||||
mock_probe.assert_called_once_with(str(temp_path))
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
@@ -163,9 +167,9 @@ class TestSoundNormalizerService:
|
|||||||
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \
|
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \
|
||||||
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \
|
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \
|
||||||
patch.object(normalizer_service, "_normalize_audio_two_pass") as mock_normalize, \
|
patch.object(normalizer_service, "_normalize_audio_two_pass") as mock_normalize, \
|
||||||
patch.object(normalizer_service, "_get_audio_duration", return_value=6000), \
|
patch("app.services.sound_normalizer.get_audio_duration", return_value=6000), \
|
||||||
patch.object(normalizer_service, "_get_file_size", return_value=2048), \
|
patch("app.services.sound_normalizer.get_file_size", return_value=2048), \
|
||||||
patch.object(normalizer_service, "_get_file_hash", return_value="new_hash"):
|
patch("app.services.sound_normalizer.get_file_hash", return_value="new_hash"):
|
||||||
|
|
||||||
# Setup path mocks
|
# Setup path mocks
|
||||||
mock_orig_path.return_value = Path("/fake/original.mp3")
|
mock_orig_path.return_value = Path("/fake/original.mp3")
|
||||||
@@ -229,9 +233,9 @@ class TestSoundNormalizerService:
|
|||||||
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \
|
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \
|
||||||
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \
|
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \
|
||||||
patch.object(normalizer_service, "_normalize_audio_one_pass") as mock_normalize, \
|
patch.object(normalizer_service, "_normalize_audio_one_pass") as mock_normalize, \
|
||||||
patch.object(normalizer_service, "_get_audio_duration", return_value=5500), \
|
patch("app.services.sound_normalizer.get_audio_duration", return_value=5500), \
|
||||||
patch.object(normalizer_service, "_get_file_size", return_value=1500), \
|
patch("app.services.sound_normalizer.get_file_size", return_value=1500), \
|
||||||
patch.object(normalizer_service, "_get_file_hash", return_value="norm_hash"):
|
patch("app.services.sound_normalizer.get_file_hash", return_value="norm_hash"):
|
||||||
|
|
||||||
# Setup path mocks
|
# Setup path mocks
|
||||||
mock_orig_path.return_value = Path("/fake/original.mp3")
|
mock_orig_path.return_value = Path("/fake/original.mp3")
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ class TestSoundScannerService:
|
|||||||
temp_path = Path(f.name)
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hash_value = scanner_service.get_file_hash(temp_path)
|
from app.utils.audio import get_file_hash
|
||||||
|
hash_value = get_file_hash(temp_path)
|
||||||
assert len(hash_value) == 64 # SHA-256 hash length
|
assert len(hash_value) == 64 # SHA-256 hash length
|
||||||
assert isinstance(hash_value, str)
|
assert isinstance(hash_value, str)
|
||||||
finally:
|
finally:
|
||||||
@@ -54,7 +55,8 @@ class TestSoundScannerService:
|
|||||||
temp_path = Path(f.name)
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
size = scanner_service.get_file_size(temp_path)
|
from app.utils.audio import get_file_size
|
||||||
|
size = get_file_size(temp_path)
|
||||||
assert size > 0
|
assert size > 0
|
||||||
assert isinstance(size, int)
|
assert isinstance(size, int)
|
||||||
finally:
|
finally:
|
||||||
@@ -74,24 +76,26 @@ class TestSoundScannerService:
|
|||||||
result = scanner_service.extract_name_from_filename(filename)
|
result = scanner_service.extract_name_from_filename(filename)
|
||||||
assert result == expected_name
|
assert result == expected_name
|
||||||
|
|
||||||
@patch("app.services.sound_scanner.ffmpeg.probe")
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
def test_get_audio_duration_success(self, mock_probe, scanner_service):
|
def test_get_audio_duration_success(self, mock_probe, scanner_service):
|
||||||
"""Test successful audio duration extraction."""
|
"""Test successful audio duration extraction."""
|
||||||
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
||||||
|
|
||||||
temp_path = Path("/fake/path/test.mp3")
|
temp_path = Path("/fake/path/test.mp3")
|
||||||
duration = scanner_service.get_audio_duration(temp_path)
|
from app.utils.audio import get_audio_duration
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
||||||
mock_probe.assert_called_once_with(str(temp_path))
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
@patch("app.services.sound_scanner.ffmpeg.probe")
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
def test_get_audio_duration_failure(self, mock_probe, scanner_service):
|
def test_get_audio_duration_failure(self, mock_probe, scanner_service):
|
||||||
"""Test audio duration extraction failure."""
|
"""Test audio duration extraction failure."""
|
||||||
mock_probe.side_effect = Exception("FFmpeg error")
|
mock_probe.side_effect = Exception("FFmpeg error")
|
||||||
|
|
||||||
temp_path = Path("/fake/path/test.mp3")
|
temp_path = Path("/fake/path/test.mp3")
|
||||||
duration = scanner_service.get_audio_duration(temp_path)
|
from app.utils.audio import get_audio_duration
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
assert duration == 0
|
assert duration == 0
|
||||||
mock_probe.assert_called_once_with(str(temp_path))
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
@@ -125,9 +129,9 @@ class TestSoundScannerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mock file operations to return same hash
|
# Mock file operations to return same hash
|
||||||
scanner_service.get_file_hash = Mock(return_value="same_hash")
|
with patch("app.services.sound_scanner.get_file_hash", return_value="same_hash"), \
|
||||||
scanner_service.get_audio_duration = Mock(return_value=120000)
|
patch("app.services.sound_scanner.get_audio_duration", return_value=120000), \
|
||||||
scanner_service.get_file_size = Mock(return_value=1024)
|
patch("app.services.sound_scanner.get_file_size", return_value=1024):
|
||||||
|
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||||
@@ -171,9 +175,9 @@ class TestSoundScannerService:
|
|||||||
scanner_service.sound_repo.create = AsyncMock(return_value=created_sound)
|
scanner_service.sound_repo.create = AsyncMock(return_value=created_sound)
|
||||||
|
|
||||||
# Mock file operations
|
# Mock file operations
|
||||||
scanner_service.get_file_hash = Mock(return_value="test_hash")
|
with patch("app.services.sound_scanner.get_file_hash", return_value="test_hash"), \
|
||||||
scanner_service.get_audio_duration = Mock(return_value=120000) # Duration in ms
|
patch("app.services.sound_scanner.get_audio_duration", return_value=120000), \
|
||||||
scanner_service.get_file_size = Mock(return_value=1024)
|
patch("app.services.sound_scanner.get_file_size", return_value=1024):
|
||||||
|
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||||
@@ -225,9 +229,9 @@ class TestSoundScannerService:
|
|||||||
scanner_service.sound_repo.update = AsyncMock(return_value=existing_sound)
|
scanner_service.sound_repo.update = AsyncMock(return_value=existing_sound)
|
||||||
|
|
||||||
# Mock file operations to return new values
|
# Mock file operations to return new values
|
||||||
scanner_service.get_file_hash = Mock(return_value="new_hash")
|
with patch("app.services.sound_scanner.get_file_hash", return_value="new_hash"), \
|
||||||
scanner_service.get_audio_duration = Mock(return_value=120000) # New duration
|
patch("app.services.sound_scanner.get_audio_duration", return_value=120000), \
|
||||||
scanner_service.get_file_size = Mock(return_value=1024) # New size
|
patch("app.services.sound_scanner.get_file_size", return_value=1024):
|
||||||
|
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||||
@@ -279,9 +283,9 @@ class TestSoundScannerService:
|
|||||||
scanner_service.sound_repo.create = AsyncMock(return_value=created_sound)
|
scanner_service.sound_repo.create = AsyncMock(return_value=created_sound)
|
||||||
|
|
||||||
# Mock file operations
|
# Mock file operations
|
||||||
scanner_service.get_file_hash = Mock(return_value="custom_hash")
|
with patch("app.services.sound_scanner.get_file_hash", return_value="custom_hash"), \
|
||||||
scanner_service.get_audio_duration = Mock(return_value=60000) # Duration in ms
|
patch("app.services.sound_scanner.get_audio_duration", return_value=60000), \
|
||||||
scanner_service.get_file_size = Mock(return_value=2048)
|
patch("app.services.sound_scanner.get_file_size", return_value=2048):
|
||||||
|
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||||
|
|||||||
292
tests/utils/test_audio.py
Normal file
292
tests/utils/test_audio.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Tests for audio utility functions."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioUtils:
|
||||||
|
"""Test audio utility functions."""
|
||||||
|
|
||||||
|
def test_get_file_hash(self):
|
||||||
|
"""Test file hash calculation."""
|
||||||
|
# Create a temporary file with known content
|
||||||
|
test_content = "test content for hashing"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write(test_content)
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate hash using our function
|
||||||
|
result_hash = get_file_hash(temp_path)
|
||||||
|
|
||||||
|
# Calculate expected hash manually
|
||||||
|
expected_hash = hashlib.sha256(test_content.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Verify the hash is correct
|
||||||
|
assert result_hash == expected_hash
|
||||||
|
assert len(result_hash) == 64 # SHA-256 hash length
|
||||||
|
assert isinstance(result_hash, str)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_hash_binary_content(self):
|
||||||
|
"""Test file hash calculation with binary content."""
|
||||||
|
# Create a temporary file with binary content
|
||||||
|
test_bytes = b"\x00\x01\x02\x03\xFF\xFE\xFD"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||||
|
f.write(test_bytes)
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate hash using our function
|
||||||
|
result_hash = get_file_hash(temp_path)
|
||||||
|
|
||||||
|
# Calculate expected hash manually
|
||||||
|
expected_hash = hashlib.sha256(test_bytes).hexdigest()
|
||||||
|
|
||||||
|
# Verify the hash is correct
|
||||||
|
assert result_hash == expected_hash
|
||||||
|
assert len(result_hash) == 64 # SHA-256 hash length
|
||||||
|
assert isinstance(result_hash, str)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_hash_empty_file(self):
|
||||||
|
"""Test file hash calculation for empty file."""
|
||||||
|
# Create an empty temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate hash using our function
|
||||||
|
result_hash = get_file_hash(temp_path)
|
||||||
|
|
||||||
|
# Calculate expected hash for empty content
|
||||||
|
expected_hash = hashlib.sha256(b"").hexdigest()
|
||||||
|
|
||||||
|
# Verify the hash is correct
|
||||||
|
assert result_hash == expected_hash
|
||||||
|
assert len(result_hash) == 64 # SHA-256 hash length
|
||||||
|
assert isinstance(result_hash, str)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_hash_large_file(self):
|
||||||
|
"""Test file hash calculation for large file (tests chunked reading)."""
|
||||||
|
# Create a large temporary file (larger than 4096 bytes chunk size)
|
||||||
|
test_content = "A" * 10000 # 10KB of 'A' characters
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write(test_content)
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate hash using our function
|
||||||
|
result_hash = get_file_hash(temp_path)
|
||||||
|
|
||||||
|
# Calculate expected hash manually
|
||||||
|
expected_hash = hashlib.sha256(test_content.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Verify the hash is correct
|
||||||
|
assert result_hash == expected_hash
|
||||||
|
assert len(result_hash) == 64 # SHA-256 hash length
|
||||||
|
assert isinstance(result_hash, str)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_size(self):
|
||||||
|
"""Test file size calculation."""
|
||||||
|
# Create a temporary file with known content
|
||||||
|
test_content = "test content for size calculation"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write(test_content)
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get size using our function
|
||||||
|
result_size = get_file_size(temp_path)
|
||||||
|
|
||||||
|
# Get expected size using pathlib directly
|
||||||
|
expected_size = temp_path.stat().st_size
|
||||||
|
|
||||||
|
# Verify the size is correct
|
||||||
|
assert result_size == expected_size
|
||||||
|
assert result_size > 0
|
||||||
|
assert isinstance(result_size, int)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_size_empty_file(self):
|
||||||
|
"""Test file size calculation for empty file."""
|
||||||
|
# Create an empty temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get size using our function
|
||||||
|
result_size = get_file_size(temp_path)
|
||||||
|
|
||||||
|
# Verify the size is zero
|
||||||
|
assert result_size == 0
|
||||||
|
assert isinstance(result_size, int)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
def test_get_file_size_binary_file(self):
|
||||||
|
"""Test file size calculation for binary file."""
|
||||||
|
# Create a temporary file with binary content
|
||||||
|
test_bytes = b"\x00\x01\x02\x03\xFF\xFE\xFD" * 100 # 700 bytes
|
||||||
|
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||||
|
f.write(test_bytes)
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get size using our function
|
||||||
|
result_size = get_file_size(temp_path)
|
||||||
|
|
||||||
|
# Verify the size is correct
|
||||||
|
assert result_size == len(test_bytes)
|
||||||
|
assert result_size == 700
|
||||||
|
assert isinstance(result_size, int)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_success(self, mock_probe):
|
||||||
|
"""Test successful audio duration extraction."""
|
||||||
|
# Mock ffmpeg.probe to return duration
|
||||||
|
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/test.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration is converted correctly (seconds to milliseconds)
|
||||||
|
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_integer_duration(self, mock_probe):
|
||||||
|
"""Test audio duration extraction with integer duration."""
|
||||||
|
# Mock ffmpeg.probe to return integer duration
|
||||||
|
mock_probe.return_value = {"format": {"duration": "60"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/test.wav")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration is converted correctly
|
||||||
|
assert duration == 60000 # 60 seconds * 1000 = 60000 ms
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_zero_duration(self, mock_probe):
|
||||||
|
"""Test audio duration extraction with zero duration."""
|
||||||
|
# Mock ffmpeg.probe to return zero duration
|
||||||
|
mock_probe.return_value = {"format": {"duration": "0.0"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/silent.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration is zero
|
||||||
|
assert duration == 0
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_fractional_duration(self, mock_probe):
|
||||||
|
"""Test audio duration extraction with fractional seconds."""
|
||||||
|
# Mock ffmpeg.probe to return fractional duration
|
||||||
|
mock_probe.return_value = {"format": {"duration": "45.123"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/test.flac")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration is converted and rounded correctly
|
||||||
|
assert duration == 45123 # 45.123 seconds * 1000 = 45123 ms
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_ffmpeg_error(self, mock_probe):
|
||||||
|
"""Test audio duration extraction when ffmpeg fails."""
|
||||||
|
# Mock ffmpeg.probe to raise an exception
|
||||||
|
mock_probe.side_effect = Exception("FFmpeg error: file not found")
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/nonexistent.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration defaults to 0 on error
|
||||||
|
assert duration == 0
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_missing_format(self, mock_probe):
|
||||||
|
"""Test audio duration extraction when format info is missing."""
|
||||||
|
# Mock ffmpeg.probe to return data without format info
|
||||||
|
mock_probe.return_value = {"streams": []}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/corrupt.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration defaults to 0 when format info is missing
|
||||||
|
assert duration == 0
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_missing_duration(self, mock_probe):
|
||||||
|
"""Test audio duration extraction when duration is missing."""
|
||||||
|
# Mock ffmpeg.probe to return format without duration
|
||||||
|
mock_probe.return_value = {"format": {"size": "1024"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/noduration.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration defaults to 0 when duration is missing
|
||||||
|
assert duration == 0
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
@patch("app.utils.audio.ffmpeg.probe")
|
||||||
|
def test_get_audio_duration_invalid_duration(self, mock_probe):
|
||||||
|
"""Test audio duration extraction with invalid duration value."""
|
||||||
|
# Mock ffmpeg.probe to return invalid duration
|
||||||
|
mock_probe.return_value = {"format": {"duration": "invalid"}}
|
||||||
|
|
||||||
|
temp_path = Path("/fake/path/invalid.mp3")
|
||||||
|
duration = get_audio_duration(temp_path)
|
||||||
|
|
||||||
|
# Verify duration defaults to 0 when duration is invalid
|
||||||
|
assert duration == 0
|
||||||
|
assert isinstance(duration, int)
|
||||||
|
mock_probe.assert_called_once_with(str(temp_path))
|
||||||
|
|
||||||
|
def test_get_file_hash_nonexistent_file(self):
|
||||||
|
"""Test file hash calculation for nonexistent file."""
|
||||||
|
nonexistent_path = Path("/fake/nonexistent/file.mp3")
|
||||||
|
|
||||||
|
# Should raise FileNotFoundError for nonexistent file
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
get_file_hash(nonexistent_path)
|
||||||
|
|
||||||
|
def test_get_file_size_nonexistent_file(self):
|
||||||
|
"""Test file size calculation for nonexistent file."""
|
||||||
|
nonexistent_path = Path("/fake/nonexistent/file.mp3")
|
||||||
|
|
||||||
|
# Should raise FileNotFoundError for nonexistent file
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
get_file_size(nonexistent_path)
|
||||||
Reference in New Issue
Block a user