feat: Add Extraction model and seed main playlist functionality

This commit is contained in:
JSC
2025-07-28 19:39:32 +02:00
parent 34e6289f92
commit c993230f98
8 changed files with 121 additions and 49 deletions

View File

@@ -8,12 +8,12 @@ from app.core.config import settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.seeds import seed_all_data from app.core.seeds import seed_all_data
from app.models import ( # noqa: F401 from app.models import ( # noqa: F401
extraction,
plan, plan,
playlist, playlist,
playlist_sound, playlist_sound,
sound, sound,
sound_played, sound_played,
stream,
user, user,
user_oauth, user_oauth,
) )

View File

@@ -5,6 +5,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.plan import Plan from app.models.plan import Plan
from app.models.playlist import Playlist
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -52,12 +53,37 @@ async def seed_plans(session: AsyncSession) -> None:
logger.info("Successfully seeded %d plans", len(initial_plans)) 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: async def seed_all_data(session: AsyncSession) -> None:
"""Seed all initial data.""" """Seed all initial data."""
logger.info("Starting data seeding") logger.info("Starting data seeding")
try: try:
await seed_plans(session) await seed_plans(session)
await seed_main_playlist(session)
logger.info("Data seeding completed successfully") logger.info("Data seeding completed successfully")
except Exception: except Exception:
logger.exception("Failed to seed data") logger.exception("Failed to seed data")

View File

@@ -9,7 +9,7 @@ if TYPE_CHECKING:
from app.models.user import User from app.models.user import User
class Stream(BaseModel, table=True): class Extraction(BaseModel, table=True):
"""Database model for a stream.""" """Database model for a stream."""
service: str = Field(nullable=False) service: str = Field(nullable=False)
@@ -30,10 +30,10 @@ class Stream(BaseModel, table=True):
UniqueConstraint( UniqueConstraint(
"service", "service",
"service_id", "service_id",
name="uq_stream_service_service_id", name="uq_extraction_service_service_id",
), ),
) )
# relationships # relationships
sound: "Sound" = Relationship(back_populates="streams") sound: "Sound" = Relationship(back_populates="extractions")
user: "User" = Relationship(back_populates="streams") user: "User" = Relationship(back_populates="extractions")

View File

@@ -5,9 +5,9 @@ from sqlmodel import Field, Relationship
from app.models.base import BaseModel from app.models.base import BaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.extraction import Extraction
from app.models.playlist_sound import PlaylistSound from app.models.playlist_sound import PlaylistSound
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.models.stream import Stream
class Sound(BaseModel, table=True): class Sound(BaseModel, table=True):
@@ -31,5 +31,5 @@ class Sound(BaseModel, table=True):
# relationships # relationships
playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="sound") 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") play_history: list["SoundPlayed"] = Relationship(back_populates="sound")

View File

@@ -6,10 +6,10 @@ from sqlmodel import Field, Relationship
from app.models.base import BaseModel from app.models.base import BaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.extraction import Extraction
from app.models.plan import Plan from app.models.plan import Plan
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.models.stream import Stream
from app.models.user_oauth import UserOauth from app.models.user_oauth import UserOauth
@@ -34,4 +34,4 @@ class User(BaseModel, table=True):
plan: "Plan" = Relationship(back_populates="users") plan: "Plan" = Relationship(back_populates="users")
playlists: list["Playlist"] = Relationship(back_populates="user") playlists: list["Playlist"] = Relationship(back_populates="user")
sounds_played: list["SoundPlayed"] = 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")

View File

@@ -16,6 +16,7 @@ dependencies = [
"python-socketio>=5.13.0", "python-socketio>=5.13.0",
"sqlmodel==0.0.24", "sqlmodel==0.0.24",
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.35.0",
"yt-dlp==2025.7.21",
] ]
[tool.uv] [tool.uv]

View File

@@ -83,6 +83,7 @@ class TestSoundNormalizerService:
try: try:
from app.utils.audio import get_file_hash from app.utils.audio import get_file_hash
hash_value = get_file_hash(temp_path) 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)
@@ -98,6 +99,7 @@ class TestSoundNormalizerService:
try: try:
from app.utils.audio import get_file_size from app.utils.audio import get_file_size
size = get_file_size(temp_path) size = get_file_size(temp_path)
assert size > 0 assert size > 0
assert isinstance(size, int) assert isinstance(size, int)
@@ -111,6 +113,7 @@ class TestSoundNormalizerService:
temp_path = Path("/fake/path/test.mp3") temp_path = Path("/fake/path/test.mp3")
from app.utils.audio import get_audio_duration from app.utils.audio import get_audio_duration
duration = get_audio_duration(temp_path) duration = get_audio_duration(temp_path)
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
@@ -123,6 +126,7 @@ class TestSoundNormalizerService:
temp_path = Path("/fake/path/test.mp3") temp_path = Path("/fake/path/test.mp3")
from app.utils.audio import get_audio_duration from app.utils.audio import get_audio_duration
duration = get_audio_duration(temp_path) duration = get_audio_duration(temp_path)
assert duration == 0 assert duration == 0
@@ -164,12 +168,20 @@ class TestSoundNormalizerService:
) )
# Mock file operations and ffmpeg # Mock file operations and ffmpeg
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \ with (
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \ patch.object(normalizer_service, "_get_original_path") as mock_orig_path,
patch.object(normalizer_service, "_normalize_audio_two_pass") as mock_normalize, \ patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path,
patch("app.services.sound_normalizer.get_audio_duration", return_value=6000), \ patch.object(
patch("app.services.sound_normalizer.get_file_size", return_value=2048), \ normalizer_service, "_normalize_audio_two_pass"
patch("app.services.sound_normalizer.get_file_hash", return_value="new_hash"): ) 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 # Setup path mocks
mock_orig_path.return_value = Path("/fake/original.mp3") mock_orig_path.return_value = Path("/fake/original.mp3")
@@ -230,12 +242,20 @@ class TestSoundNormalizerService:
is_normalized=False, is_normalized=False,
) )
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \ with (
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path, \ patch.object(normalizer_service, "_get_original_path") as mock_orig_path,
patch.object(normalizer_service, "_normalize_audio_one_pass") as mock_normalize, \ patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path,
patch("app.services.sound_normalizer.get_audio_duration", return_value=5500), \ patch.object(
patch("app.services.sound_normalizer.get_file_size", return_value=1500), \ normalizer_service, "_normalize_audio_one_pass"
patch("app.services.sound_normalizer.get_file_hash", return_value="norm_hash"): ) 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 # Setup path mocks
mock_orig_path.return_value = Path("/fake/original.mp3") mock_orig_path.return_value = Path("/fake/original.mp3")
@@ -270,16 +290,22 @@ class TestSoundNormalizerService:
is_normalized=False, is_normalized=False,
) )
with patch.object(normalizer_service, "_get_original_path") as mock_orig_path, \ with (
patch.object(normalizer_service, "_get_normalized_path") as mock_norm_path: 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 # Setup path mocks
mock_orig_path.return_value = Path("/fake/original.mp3") mock_orig_path.return_value = Path("/fake/original.mp3")
mock_norm_path.return_value = Path("/fake/normalized.mp3") mock_norm_path.return_value = Path("/fake/normalized.mp3")
# Mock file existence but normalization fails # Mock file existence but normalization fails
with patch("pathlib.Path.exists", return_value=True), \ with (
patch.object(normalizer_service, "_normalize_audio_two_pass") as mock_normalize: 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") mock_normalize.side_effect = Exception("Normalization failed")
@@ -316,7 +342,9 @@ class TestSoundNormalizerService:
] ]
# Mock repository calls # 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 # Mock individual normalization
with patch.object(normalizer_service, "normalize_sound") as mock_normalize: with patch.object(normalizer_service, "normalize_sound") as mock_normalize:
@@ -403,7 +431,9 @@ class TestSoundNormalizerService:
assert len(results["files"]) == 1 assert len(results["files"]) == 1
# Verify correct repository method was called # 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 @pytest.mark.asyncio
async def test_normalize_sounds_with_errors(self, normalizer_service): async def test_normalize_sounds_with_errors(self, normalizer_service):
@@ -432,7 +462,9 @@ class TestSoundNormalizerService:
] ]
# Mock repository calls # 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 # Mock individual normalization with one success and one error
with patch.object(normalizer_service, "normalize_sound") as mock_normalize: with patch.object(normalizer_service, "normalize_sound") as mock_normalize:
@@ -500,7 +532,9 @@ class TestSoundNormalizerService:
# Verify ffmpeg chain was called correctly # Verify ffmpeg chain was called correctly
mock_ffmpeg.input.assert_called_once_with(str(input_path)) 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.output.assert_called_once()
mock_ffmpeg.run.assert_called_once() mock_ffmpeg.run.assert_called_once()
@@ -530,13 +564,13 @@ class TestSoundNormalizerService:
mock_ffmpeg.overwrite_output.return_value = mock_stream mock_ffmpeg.overwrite_output.return_value = mock_stream
# Mock analysis output with valid JSON # Mock analysis output with valid JSON
analysis_json = '''{ analysis_json = """{
"input_i": "-23.0", "input_i": "-23.0",
"input_lra": "11.0", "input_lra": "11.0",
"input_tp": "-2.0", "input_tp": "-2.0",
"input_thresh": "-33.0", "input_thresh": "-33.0",
"target_offset": "0.0" "target_offset": "0.0"
}''' }"""
mock_ffmpeg.run.side_effect = [ mock_ffmpeg.run.side_effect = [
(None, analysis_json.encode("utf-8")), # First pass analysis (None, analysis_json.encode("utf-8")), # First pass analysis

11
uv.lock generated
View File

@@ -53,6 +53,7 @@ dependencies = [
{ name = "python-socketio" }, { name = "python-socketio" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "yt-dlp" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -79,6 +80,7 @@ requires-dist = [
{ name = "python-socketio", specifier = ">=5.13.0" }, { name = "python-socketio", specifier = ">=5.13.0" },
{ name = "sqlmodel", specifier = "==0.0.24" }, { name = "sqlmodel", specifier = "==0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
{ name = "yt-dlp", specifier = "==2025.7.21" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1199,3 +1201,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d77642
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, { 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 },
]