Compare commits

..

2 Commits

Author SHA1 Message Date
JSC
f17b2d053e feat: Add Backend CI workflow for linting and setup
Some checks failed
Backend CI / lint (push) Failing after 2m43s
2025-07-30 22:27:54 +02:00
JSC
4e3c489f31 feat: Refactor playlist handling in PlayerService and add comprehensive tests 2025-07-30 22:10:23 +02:00
3 changed files with 392 additions and 30 deletions

31
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,31 @@
name: Backend CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: "Set up python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install requirements
run: uv sync --locked --all-extras --dev
- name: Run linter
run: uv run ruff check

View File

@@ -5,12 +5,12 @@ import threading
import time import time
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from enum import Enum from enum import Enum
from pathlib import Path
from typing import Any from typing import Any
import vlc # type: ignore[import-untyped] import vlc # type: ignore[import-untyped]
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.playlist import Playlist
from app.models.sound import Sound from app.models.sound import Sound
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.repositories.playlist import PlaylistRepository from app.repositories.playlist import PlaylistRepository
@@ -316,39 +316,11 @@ class PlayerService:
session = self.db_session_factory() session = self.db_session_factory()
try: try:
playlist_repo = PlaylistRepository(session) playlist_repo = PlaylistRepository(session)
# Get main playlist (fallback for now)
current_playlist = await playlist_repo.get_main_playlist() current_playlist = await playlist_repo.get_main_playlist()
if current_playlist and current_playlist.id: if current_playlist and current_playlist.id:
# Load playlist sounds
sounds = await playlist_repo.get_playlist_sounds(current_playlist.id) sounds = await playlist_repo.get_playlist_sounds(current_playlist.id)
await self._handle_playlist_reload(current_playlist, sounds)
# Update state
self.state.playlist_id = current_playlist.id
self.state.playlist_name = current_playlist.name
self.state.playlist_sounds = sounds
self.state.playlist_length = len(sounds)
self.state.playlist_duration = sum(
sound.duration or 0 for sound in sounds
)
# Reset current sound if playlist changed
if self.state.current_sound_id and not any(
s.id == self.state.current_sound_id for s in sounds
):
self.state.current_sound_id = None
self.state.current_sound_index = None
self.state.current_sound = None
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
# Set first track as current if no current track and playlist has sounds
if not self.state.current_sound_id and sounds:
self.state.current_sound_index = 0
self.state.current_sound = sounds[0]
self.state.current_sound_id = sounds[0].id
logger.info( logger.info(
"Loaded playlist: %s (%s sounds)", "Loaded playlist: %s (%s sounds)",
current_playlist.name, current_playlist.name,
@@ -361,6 +333,132 @@ class PlayerService:
await self._broadcast_state() await self._broadcast_state()
async def _handle_playlist_reload(
self,
current_playlist: Playlist,
sounds: list[Sound],
) -> None:
"""Handle playlist reload logic with ID comparison."""
# Store previous state for comparison
previous_playlist_id = self.state.playlist_id
previous_current_sound_id = self.state.current_sound_id
previous_current_sound_index = self.state.current_sound_index
# Update basic playlist state
self._update_playlist_state(current_playlist, sounds)
# Handle playlist changes based on ID comparison
if (
current_playlist.id is not None
and previous_playlist_id != current_playlist.id
):
await self._handle_playlist_id_changed(
previous_playlist_id, current_playlist.id, sounds,
)
elif previous_current_sound_id:
await self._handle_same_playlist_track_check(
previous_current_sound_id,
previous_current_sound_index,
sounds,
)
elif sounds:
self._set_first_track_as_current(sounds)
async def _handle_playlist_id_changed(
self,
previous_id: int | None,
current_id: int,
sounds: list[Sound],
) -> None:
"""Handle when playlist ID changes - stop player and reset to first track."""
logger.info(
"Playlist changed from %s to %s - stopping player and resetting",
previous_id,
current_id,
)
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
if sounds:
self._set_first_track_as_current(sounds)
else:
self._clear_current_track()
async def _handle_same_playlist_track_check(
self,
previous_sound_id: int,
previous_index: int | None,
sounds: list[Sound],
) -> None:
"""Handle track checking when playlist ID is the same."""
# Find the current track in the new playlist
new_index = self._find_sound_index(previous_sound_id, sounds)
if new_index is not None:
# Track still exists - update index if it changed
if new_index != previous_index:
logger.info(
"Current track %s moved from index %s to %s",
previous_sound_id,
previous_index,
new_index,
)
# Always set the index and sound reference
self.state.current_sound_index = new_index
self.state.current_sound = sounds[new_index]
else:
# Current track no longer exists in playlist
await self._handle_track_removed(previous_sound_id, sounds)
async def _handle_track_removed(
self,
previous_sound_id: int,
sounds: list[Sound],
) -> None:
"""Handle when current track no longer exists in playlist."""
logger.info(
"Current track %s no longer exists in playlist - stopping and resetting",
previous_sound_id,
)
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
if sounds:
self._set_first_track_as_current(sounds)
else:
self._clear_current_track()
def _update_playlist_state(
self, current_playlist: Playlist, sounds: list[Sound],
) -> None:
"""Update basic playlist state information."""
self.state.playlist_id = current_playlist.id
self.state.playlist_name = current_playlist.name
self.state.playlist_sounds = sounds
self.state.playlist_length = len(sounds)
self.state.playlist_duration = sum(sound.duration or 0 for sound in sounds)
def _find_sound_index(self, sound_id: int, sounds: list[Sound]) -> int | None:
"""Find the index of a sound in the sounds list."""
for i, sound in enumerate(sounds):
if sound.id == sound_id:
return i
return None
def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
"""Set the first track as the current track."""
self.state.current_sound_index = 0
self.state.current_sound = sounds[0]
self.state.current_sound_id = sounds[0].id
def _clear_current_track(self) -> None:
"""Clear the current track state."""
self.state.current_sound_index = None
self.state.current_sound = None
self.state.current_sound_id = None
def get_state(self) -> dict[str, Any]: def get_state(self) -> dict[str, Any]:
"""Get current player state.""" """Get current player state."""
return self.state.to_dict() return self.state.to_dict()

View File

@@ -386,6 +386,239 @@ class TestPlayerService:
assert player_service.state.playlist_length == 2 assert player_service.state.playlist_length == 2
assert player_service.state.playlist_duration == 75000 assert player_service.state.playlist_duration == 75000
@pytest.mark.asyncio
async def test_handle_playlist_id_changed(self, player_service):
"""Test handling when playlist ID changes."""
# Setup initial state
player_service.state.status = PlayerStatus.PLAYING
player_service.state.current_sound_id = 1
player_service.state.current_sound_index = 0
# Create test sounds
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1, sound2]
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock) as mock_stop:
await player_service._handle_playlist_id_changed(1, 2, sounds)
# Should stop playback and set first track as current
mock_stop.assert_called_once()
assert player_service.state.current_sound_index == 0
assert player_service.state.current_sound == sound1
assert player_service.state.current_sound_id == 1
@pytest.mark.asyncio
async def test_handle_playlist_id_changed_empty_playlist(self, player_service):
"""Test handling playlist ID change with empty playlist."""
player_service.state.status = PlayerStatus.PLAYING
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock) as mock_stop:
await player_service._handle_playlist_id_changed(1, 2, [])
mock_stop.assert_called_once()
assert player_service.state.current_sound_index is None
assert player_service.state.current_sound is None
assert player_service.state.current_sound_id is None
@pytest.mark.asyncio
async def test_handle_same_playlist_track_exists_same_index(self, player_service):
"""Test handling same playlist when track exists at same index."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1, sound2]
await player_service._handle_same_playlist_track_check(1, 0, sounds)
# Should update sound object reference but keep same index
assert player_service.state.current_sound_index == 0 # Should be set to 0 from new_index
assert player_service.state.current_sound == sound1
@pytest.mark.asyncio
async def test_handle_same_playlist_track_exists_different_index(self, player_service):
"""Test handling same playlist when track exists at different index."""
sound1 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sound2 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sounds = [sound1, sound2] # Track with ID 1 is now at index 1
await player_service._handle_same_playlist_track_check(1, 0, sounds)
# Should update index and sound reference
assert player_service.state.current_sound_index == 1
assert player_service.state.current_sound == sound2
@pytest.mark.asyncio
async def test_handle_same_playlist_track_not_found(self, player_service):
"""Test handling same playlist when current track no longer exists."""
player_service.state.status = PlayerStatus.PLAYING
sound1 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sound2 = Sound(id=3, name="Song 3", filename="song3.mp3", duration=60000)
sounds = [sound1, sound2] # Track with ID 1 is missing
with patch.object(player_service, "_handle_track_removed", new_callable=AsyncMock) as mock_removed:
await player_service._handle_same_playlist_track_check(1, 0, sounds)
mock_removed.assert_called_once_with(1, sounds)
@pytest.mark.asyncio
async def test_handle_track_removed_with_sounds(self, player_service):
"""Test handling when current track is removed with sounds available."""
player_service.state.status = PlayerStatus.PLAYING
sound1 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1]
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock) as mock_stop:
await player_service._handle_track_removed(1, sounds)
mock_stop.assert_called_once()
assert player_service.state.current_sound_index == 0
assert player_service.state.current_sound == sound1
assert player_service.state.current_sound_id == 2
@pytest.mark.asyncio
async def test_handle_track_removed_empty_playlist(self, player_service):
"""Test handling when current track is removed with empty playlist."""
player_service.state.status = PlayerStatus.PLAYING
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock) as mock_stop:
await player_service._handle_track_removed(1, [])
mock_stop.assert_called_once()
assert player_service.state.current_sound_index is None
assert player_service.state.current_sound is None
assert player_service.state.current_sound_id is None
def test_update_playlist_state(self, player_service):
"""Test updating playlist state information."""
mock_playlist = Mock()
mock_playlist.id = 5
mock_playlist.name = "New Playlist"
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1, sound2]
player_service._update_playlist_state(mock_playlist, sounds)
assert player_service.state.playlist_id == 5
assert player_service.state.playlist_name == "New Playlist"
assert player_service.state.playlist_sounds == sounds
assert player_service.state.playlist_length == 2
assert player_service.state.playlist_duration == 75000
def test_find_sound_index_found(self, player_service):
"""Test finding sound index when sound exists."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1, sound2]
index = player_service._find_sound_index(2, sounds)
assert index == 1
def test_find_sound_index_not_found(self, player_service):
"""Test finding sound index when sound doesn't exist."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sounds = [sound1]
index = player_service._find_sound_index(999, sounds)
assert index is None
def test_set_first_track_as_current(self, player_service):
"""Test setting first track as current."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sounds = [sound1, sound2]
player_service._set_first_track_as_current(sounds)
assert player_service.state.current_sound_index == 0
assert player_service.state.current_sound == sound1
assert player_service.state.current_sound_id == 1
def test_clear_current_track(self, player_service):
"""Test clearing current track state."""
# Set some initial state
player_service.state.current_sound_index = 2
player_service.state.current_sound = Mock()
player_service.state.current_sound_id = 5
player_service._clear_current_track()
assert player_service.state.current_sound_index is None
assert player_service.state.current_sound is None
assert player_service.state.current_sound_id is None
@pytest.mark.asyncio
async def test_reload_playlist_different_id_scenario(self, player_service):
"""Test complete reload scenario when playlist ID changes."""
# Setup current state
player_service.state.playlist_id = 1
player_service.state.current_sound_id = 1
player_service.state.current_sound_index = 0
player_service.state.status = PlayerStatus.PLAYING
mock_session = AsyncMock()
player_service.db_session_factory = lambda: mock_session
with patch("app.services.player.PlaylistRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
# Mock new playlist with different ID
mock_playlist = Mock()
mock_playlist.id = 2 # Different ID
mock_playlist.name = "New Playlist"
mock_repo.get_main_playlist.return_value = mock_playlist
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
mock_sounds = [sound1]
mock_repo.get_playlist_sounds.return_value = mock_sounds
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock) as mock_stop:
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.reload_playlist()
# Should stop and reset to first track
mock_stop.assert_called_once()
assert player_service.state.playlist_id == 2
assert player_service.state.current_sound_index == 0
assert player_service.state.current_sound_id == 1
@pytest.mark.asyncio
async def test_reload_playlist_same_id_track_moved(self, player_service):
"""Test reload when playlist ID same but track moved to different index."""
# Setup current state
player_service.state.playlist_id = 1
player_service.state.current_sound_id = 2 # Currently playing track 2
player_service.state.current_sound_index = 1 # At index 1
mock_session = AsyncMock()
player_service.db_session_factory = lambda: mock_session
with patch("app.services.player.PlaylistRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
# Same playlist ID
mock_playlist = Mock()
mock_playlist.id = 1
mock_playlist.name = "Same Playlist"
mock_repo.get_main_playlist.return_value = mock_playlist
# Track 2 moved to index 0
sound1 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
sound2 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
mock_sounds = [sound1, sound2] # Track 2 now at index 0
mock_repo.get_playlist_sounds.return_value = mock_sounds
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.reload_playlist()
# Should update index but keep same track
assert player_service.state.playlist_id == 1
assert player_service.state.current_sound_index == 0 # Updated index
assert player_service.state.current_sound_id == 2 # Same track
assert player_service.state.current_sound == sound1
def test_get_next_index_continuous_mode(self, player_service): def test_get_next_index_continuous_mode(self, player_service):
"""Test getting next index in continuous mode.""" """Test getting next index in continuous mode."""