diff --git a/app/core/database.py b/app/core/database.py index 0a85ffe..1d7d200 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -8,12 +8,12 @@ from app.core.config import settings from app.core.logging import get_logger from app.core.seeds import seed_all_data from app.models import ( # noqa: F401 + extraction, plan, playlist, playlist_sound, sound, sound_played, - stream, user, user_oauth, ) diff --git a/app/core/seeds.py b/app/core/seeds.py index 8a9f05d..b65e93d 100644 --- a/app/core/seeds.py +++ b/app/core/seeds.py @@ -5,6 +5,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.logging import get_logger from app.models.plan import Plan +from app.models.playlist import Playlist logger = get_logger(__name__) @@ -52,12 +53,37 @@ async def seed_plans(session: AsyncSession) -> None: logger.info("Successfully seeded %d plans", len(initial_plans)) +async def seed_main_playlist(session: AsyncSession) -> None: + """Seed the main playlist with initial data.""" + logger.info("Seeding main playlist data") + + # Check if the main playlist already exists + existing_playlist = await session.exec(select(Playlist).where(Playlist.is_main)) + if existing_playlist.first(): + logger.info("Main playlist already exists, skipping seeding") + return + + # Create the main playlist + main_playlist = Playlist( + name="All", + description="The default main playlist with all the tracks", + is_main=True, + is_deletable=False, + is_current=True, + ) + session.add(main_playlist) + + await session.commit() + logger.info("Successfully seeded main playlist") + + async def seed_all_data(session: AsyncSession) -> None: """Seed all initial data.""" logger.info("Starting data seeding") try: await seed_plans(session) + await seed_main_playlist(session) logger.info("Data seeding completed successfully") except Exception: logger.exception("Failed to seed data") diff --git a/app/models/stream.py b/app/models/extraction.py similarity index 82% rename from app/models/stream.py rename to app/models/extraction.py index bd9ee10..80aadf5 100644 --- a/app/models/stream.py +++ b/app/models/extraction.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from app.models.user import User -class Stream(BaseModel, table=True): +class Extraction(BaseModel, table=True): """Database model for a stream.""" service: str = Field(nullable=False) @@ -30,10 +30,10 @@ class Stream(BaseModel, table=True): UniqueConstraint( "service", "service_id", - name="uq_stream_service_service_id", + name="uq_extraction_service_service_id", ), ) # relationships - sound: "Sound" = Relationship(back_populates="streams") - user: "User" = Relationship(back_populates="streams") + sound: "Sound" = Relationship(back_populates="extractions") + user: "User" = Relationship(back_populates="extractions") diff --git a/app/models/sound.py b/app/models/sound.py index aa2a9a6..93b9449 100644 --- a/app/models/sound.py +++ b/app/models/sound.py @@ -5,9 +5,9 @@ from sqlmodel import Field, Relationship from app.models.base import BaseModel if TYPE_CHECKING: + from app.models.extraction import Extraction from app.models.playlist_sound import PlaylistSound from app.models.sound_played import SoundPlayed - from app.models.stream import Stream class Sound(BaseModel, table=True): @@ -31,5 +31,5 @@ class Sound(BaseModel, table=True): # relationships playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="sound") - streams: list["Stream"] = Relationship(back_populates="sound") + extractions: list["Extraction"] = Relationship(back_populates="sound") play_history: list["SoundPlayed"] = Relationship(back_populates="sound") diff --git a/app/models/user.py b/app/models/user.py index 3c3d2fd..a1c3cbb 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -6,10 +6,10 @@ from sqlmodel import Field, Relationship from app.models.base import BaseModel if TYPE_CHECKING: + from app.models.extraction import Extraction from app.models.plan import Plan from app.models.playlist import Playlist from app.models.sound_played import SoundPlayed - from app.models.stream import Stream from app.models.user_oauth import UserOauth @@ -34,4 +34,4 @@ class User(BaseModel, table=True): plan: "Plan" = Relationship(back_populates="users") playlists: list["Playlist"] = Relationship(back_populates="user") sounds_played: list["SoundPlayed"] = Relationship(back_populates="user") - streams: list["Stream"] = Relationship(back_populates="user") + extractions: list["Extraction"] = Relationship(back_populates="user") diff --git a/pyproject.toml b/pyproject.toml index 704f470..f0ffe70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-socketio>=5.13.0", "sqlmodel==0.0.24", "uvicorn[standard]==0.35.0", + "yt-dlp==2025.7.21", ] [tool.uv] diff --git a/tests/services/test_sound_normalizer.py b/tests/services/test_sound_normalizer.py index dadf75b..d562bcb 100644 --- a/tests/services/test_sound_normalizer.py +++ b/tests/services/test_sound_normalizer.py @@ -53,7 +53,7 @@ class TestSoundNormalizerService: ) normalized_path = normalizer_service._get_normalized_path(sound) - + assert "sounds/normalized/soundboard" in str(normalized_path) assert "test_audio.mp3" == normalized_path.name @@ -70,7 +70,7 @@ class TestSoundNormalizerService: ) original_path = normalizer_service._get_original_path(sound) - + assert "sounds/originals/soundboard" in str(original_path) assert "test_audio.wav" == original_path.name @@ -83,6 +83,7 @@ class TestSoundNormalizerService: try: 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 isinstance(hash_value, str) @@ -98,6 +99,7 @@ class TestSoundNormalizerService: try: from app.utils.audio import get_file_size + size = get_file_size(temp_path) assert size > 0 assert isinstance(size, int) @@ -111,6 +113,7 @@ class TestSoundNormalizerService: temp_path = Path("/fake/path/test.mp3") from app.utils.audio import get_audio_duration + duration = get_audio_duration(temp_path) assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms @@ -123,6 +126,7 @@ class TestSoundNormalizerService: temp_path = Path("/fake/path/test.mp3") from app.utils.audio import get_audio_duration + duration = get_audio_duration(temp_path) assert duration == 0 @@ -155,7 +159,7 @@ class TestSoundNormalizerService: sound = Sound( id=1, type="SDB", - name="Test Sound", + name="Test Sound", filename="test.mp3", duration=5000, size=1024, @@ -164,17 +168,25 @@ class TestSoundNormalizerService: ) # Mock file operations and ffmpeg - 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, "_normalize_audio_two_pass") as mock_normalize, \ - patch("app.services.sound_normalizer.get_audio_duration", return_value=6000), \ - patch("app.services.sound_normalizer.get_file_size", return_value=2048), \ - patch("app.services.sound_normalizer.get_file_hash", return_value="new_hash"): + 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, "_normalize_audio_two_pass" + ) as mock_normalize, + patch( + "app.services.sound_normalizer.get_audio_duration", return_value=6000 + ), + patch("app.services.sound_normalizer.get_file_size", return_value=2048), + patch( + "app.services.sound_normalizer.get_file_hash", return_value="new_hash" + ), + ): # Setup path mocks mock_orig_path.return_value = Path("/fake/original.mp3") mock_norm_path.return_value = Path("/fake/normalized.mp3") - + # Mock file existence with patch("pathlib.Path.exists", return_value=True): # Mock repository update @@ -207,7 +219,7 @@ class TestSoundNormalizerService: with patch.object(normalizer_service, "_get_original_path") as mock_path: mock_path.return_value = Path("/fake/missing.mp3") - + # Mock file doesn't exist with patch("pathlib.Path.exists", return_value=False): result = await normalizer_service.normalize_sound(sound) @@ -230,17 +242,25 @@ class TestSoundNormalizerService: is_normalized=False, ) - 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, "_normalize_audio_one_pass") as mock_normalize, \ - patch("app.services.sound_normalizer.get_audio_duration", return_value=5500), \ - patch("app.services.sound_normalizer.get_file_size", return_value=1500), \ - patch("app.services.sound_normalizer.get_file_hash", return_value="norm_hash"): + 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, "_normalize_audio_one_pass" + ) as mock_normalize, + patch( + "app.services.sound_normalizer.get_audio_duration", return_value=5500 + ), + patch("app.services.sound_normalizer.get_file_size", return_value=1500), + patch( + "app.services.sound_normalizer.get_file_hash", return_value="norm_hash" + ), + ): # Setup path mocks mock_orig_path.return_value = Path("/fake/original.mp3") mock_norm_path.return_value = Path("/fake/normalized.mp3") - + # Mock file existence with patch("pathlib.Path.exists", return_value=True): # Mock repository update @@ -252,7 +272,7 @@ class TestSoundNormalizerService: assert result["normalized_duration"] == 5500 assert result["normalized_size"] == 1500 assert result["normalized_hash"] == "norm_hash" - + # Verify one-pass was used mock_normalize.assert_called_once() @@ -270,17 +290,23 @@ class TestSoundNormalizerService: is_normalized=False, ) - with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \ - patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path: + with ( + patch.object(normalizer_service, "_get_original_path") as mock_orig_path, + patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, + ): # Setup path mocks mock_orig_path.return_value = Path("/fake/original.mp3") mock_norm_path.return_value = Path("/fake/normalized.mp3") - + # Mock file existence but normalization fails - with patch("pathlib.Path.exists", return_value=True), \ - patch.object(normalizer_service, "_normalize_audio_two_pass") as mock_normalize: - + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object( + normalizer_service, "_normalize_audio_two_pass" + ) as mock_normalize, + ): + mock_normalize.side_effect = Exception("Normalization failed") result = await normalizer_service.normalize_sound(sound) @@ -306,7 +332,7 @@ class TestSoundNormalizerService: Sound( id=2, type="TTS", - name="Sound 2", + name="Sound 2", filename="sound2.wav", duration=3000, size=512, @@ -316,8 +342,10 @@ class TestSoundNormalizerService: ] # Mock repository calls - normalizer_service.sound_repo.get_unnormalized_sounds = AsyncMock(return_value=sounds) - + normalizer_service.sound_repo.get_unnormalized_sounds = AsyncMock( + return_value=sounds + ) + # Mock individual normalization with patch.object(normalizer_service, "normalize_sound") as mock_normalize: mock_normalize.side_effect = [ @@ -377,7 +405,7 @@ class TestSoundNormalizerService: normalizer_service.sound_repo.get_unnormalized_sounds_by_type = AsyncMock( return_value=sdb_sounds ) - + # Mock individual normalization with patch.object(normalizer_service, "normalize_sound") as mock_normalize: mock_normalize.return_value = { @@ -403,7 +431,9 @@ class TestSoundNormalizerService: assert len(results["files"]) == 1 # Verify correct repository method was called - normalizer_service.sound_repo.get_unnormalized_sounds_by_type.assert_called_once_with("SDB") + normalizer_service.sound_repo.get_unnormalized_sounds_by_type.assert_called_once_with( + "SDB" + ) @pytest.mark.asyncio async def test_normalize_sounds_with_errors(self, normalizer_service): @@ -432,8 +462,10 @@ class TestSoundNormalizerService: ] # Mock repository calls - normalizer_service.sound_repo.get_unnormalized_sounds = AsyncMock(return_value=sounds) - + normalizer_service.sound_repo.get_unnormalized_sounds = AsyncMock( + return_value=sounds + ) + # Mock individual normalization with one success and one error with patch.object(normalizer_service, "normalize_sound") as mock_normalize: mock_normalize.side_effect = [ @@ -481,7 +513,7 @@ class TestSoundNormalizerService: @pytest.mark.asyncio @patch("app.services.sound_normalizer.ffmpeg") async def test_normalize_audio_one_pass_mp3( - self, + self, mock_ffmpeg, normalizer_service, ): @@ -500,7 +532,9 @@ class TestSoundNormalizerService: # Verify ffmpeg chain was called correctly mock_ffmpeg.input.assert_called_once_with(str(input_path)) - mock_ffmpeg.filter.assert_called_once_with(mock_stream, "loudnorm", I=-23, TP=-2, LRA=7) + mock_ffmpeg.filter.assert_called_once_with( + mock_stream, "loudnorm", I=-23, TP=-2, LRA=7 + ) mock_ffmpeg.output.assert_called_once() mock_ffmpeg.run.assert_called_once() @@ -522,7 +556,7 @@ class TestSoundNormalizerService: input_path = Path("/fake/input.wav") output_path = Path("/fake/output.mp3") - # Mock ffmpeg chain + # Mock ffmpeg chain mock_stream = Mock() mock_ffmpeg.input.return_value = mock_stream mock_ffmpeg.filter.return_value = mock_stream @@ -530,14 +564,14 @@ class TestSoundNormalizerService: mock_ffmpeg.overwrite_output.return_value = mock_stream # Mock analysis output with valid JSON - analysis_json = '''{ + analysis_json = """{ "input_i": "-23.0", "input_lra": "11.0", "input_tp": "-2.0", "input_thresh": "-33.0", "target_offset": "0.0" - }''' - + }""" + mock_ffmpeg.run.side_effect = [ (None, analysis_json.encode("utf-8")), # First pass analysis None, # Second pass normalization @@ -554,10 +588,10 @@ class TestSoundNormalizerService: assert first_filter_call[1]["print_format"] == "json" # Verify second pass used measured values - second_filter_call = mock_ffmpeg.filter.call_args_list[1] + second_filter_call = mock_ffmpeg.filter.call_args_list[1] measured_args = second_filter_call[1] assert "measured_I" in measured_args assert "measured_LRA" in measured_args assert "measured_TP" in measured_args assert "measured_thresh" in measured_args - assert "offset" in measured_args \ No newline at end of file + assert "offset" in measured_args diff --git a/uv.lock b/uv.lock index d421b5d..c8847e9 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,7 @@ dependencies = [ { name = "python-socketio" }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, + { name = "yt-dlp" }, ] [package.dev-dependencies] @@ -79,6 +80,7 @@ requires-dist = [ { name = "python-socketio", specifier = ">=5.13.0" }, { name = "sqlmodel", specifier = "==0.0.24" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, + { name = "yt-dlp", specifier = "==2025.7.21" }, ] [package.metadata.requires-dev] @@ -1199,3 +1201,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d77642 wheels = [ { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, ] + +[[package]] +name = "yt-dlp" +version = "2025.7.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/3a/343f7a0024ddd4c30f150e8d8f57fd7b924846f97d99fc0dcd75ea8d2773/yt_dlp-2025.7.21.tar.gz", hash = "sha256:46fbb53eab1afbe184c45b4c17e9a6eba614be680e4c09de58b782629d0d7f43", size = 3050219 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2f/abe59a3204c749fed494849ea29176bcefa186ec8898def9e43f649ddbcf/yt_dlp-2025.7.21-py3-none-any.whl", hash = "sha256:d7aa2b53f9b2f35453346360f41811a0dad1e956e70b35a4ae95039d4d815d15", size = 3288681 }, +]