From 1b0d291ad30847f784d4806d030935116b049ac6 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 30 Jul 2025 01:22:24 +0200 Subject: [PATCH] Add comprehensive tests for player API endpoints and player service functionality - Implemented tests for player API endpoints including play, pause, stop, next, previous, seek, set volume, set mode, reload playlist, and get state. - Added mock player service for testing API interactions. - Created tests for player service methods including play, pause, stop playback, next, previous, seek, set volume, set mode, and reload playlist. - Ensured proper handling of success, error, and edge cases in both API and service tests. - Verified state management and serialization in player state tests. --- app/api/v1/__init__.py | 3 +- app/api/v1/player.py | 228 +++++++++ app/core/database.py | 7 + app/main.py | 11 +- app/services/extraction.py | 31 +- app/services/player.py | 637 +++++++++++++++++++++++++ pyproject.toml | 7 +- tests/api/v1/test_player_endpoints.py | 658 ++++++++++++++++++++++++++ tests/services/test_extraction.py | 14 +- tests/services/test_player.py | 623 ++++++++++++++++++++++++ uv.lock | 170 +++---- 11 files changed, 2291 insertions(+), 98 deletions(-) create mode 100644 app/api/v1/player.py create mode 100644 app/services/player.py create mode 100644 tests/api/v1/test_player_endpoints.py create mode 100644 tests/services/test_player.py diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index f54eaaf..b01894e 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api.v1 import auth, main, playlists, socket, sounds +from app.api.v1 import auth, main, player, playlists, socket, sounds # V1 API router with v1 prefix api_router = APIRouter(prefix="/v1") @@ -10,6 +10,7 @@ api_router = APIRouter(prefix="/v1") # Include all route modules api_router.include_router(auth.router, tags=["authentication"]) api_router.include_router(main.router, tags=["main"]) +api_router.include_router(player.router, tags=["player"]) api_router.include_router(playlists.router, tags=["playlists"]) api_router.include_router(socket.router, tags=["socket"]) api_router.include_router(sounds.router, tags=["sounds"]) diff --git a/app/api/v1/player.py b/app/api/v1/player.py new file mode 100644 index 0000000..7e03f97 --- /dev/null +++ b/app/api/v1/player.py @@ -0,0 +1,228 @@ +"""Player API endpoints.""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from app.core.dependencies import get_current_active_user_flexible +from app.core.logging import get_logger +from app.models.user import User +from app.services.player import PlayerMode, get_player_service + +logger = get_logger(__name__) + +router = APIRouter(prefix="/player", tags=["player"]) + + +class SeekRequest(BaseModel): + """Request model for seek operation.""" + + position_ms: int = Field(ge=0, description="Position in milliseconds") + + +class VolumeRequest(BaseModel): + """Request model for volume control.""" + + volume: int = Field(ge=0, le=100, description="Volume level (0-100)") + + +class ModeRequest(BaseModel): + """Request model for mode change.""" + + mode: PlayerMode = Field(description="Playback mode") + + +@router.post("/play") +async def play( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Play current sound.""" + try: + player = get_player_service() + await player.play() + return {"message": "Playback started"} + except Exception as e: + logger.exception("Error starting playback") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to start playback", + ) from e + + +@router.post("/play/{index}") +async def play_at_index( + index: int, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Play sound at specific index.""" + try: + player = get_player_service() + await player.play(index) + return {"message": f"Playing sound at index {index}"} + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except Exception as e: + logger.exception("Error playing sound at index %s", index) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to play sound", + ) from e + + +@router.post("/pause") +async def pause( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Pause playback.""" + try: + player = get_player_service() + await player.pause() + return {"message": "Playback paused"} + except Exception as e: + logger.exception("Error pausing playback") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to pause playback", + ) from e + + +@router.post("/stop") +async def stop( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Stop playback.""" + try: + player = get_player_service() + await player.stop_playback() + return {"message": "Playback stopped"} + except Exception as e: + logger.exception("Error stopping playback") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to stop playback", + ) from e + + +@router.post("/next") +async def next_track( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Skip to next track.""" + try: + player = get_player_service() + await player.next() + return {"message": "Skipped to next track"} + except Exception as e: + logger.exception("Error skipping to next track") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to skip to next track", + ) from e + + +@router.post("/previous") +async def previous_track( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Go to previous track.""" + try: + player = get_player_service() + await player.previous() + return {"message": "Went to previous track"} + except Exception as e: + logger.exception("Error going to previous track") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to go to previous track", + ) from e + + +@router.post("/seek") +async def seek( + request: SeekRequest, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Seek to specific position in current track.""" + try: + player = get_player_service() + await player.seek(request.position_ms) + return {"message": f"Seeked to position {request.position_ms}ms"} + except Exception as e: + logger.exception("Error seeking to position %s", request.position_ms) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to seek", + ) from e + + +@router.post("/volume") +async def set_volume( + request: VolumeRequest, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Set playback volume.""" + try: + player = get_player_service() + await player.set_volume(request.volume) + return {"message": f"Volume set to {request.volume}"} + except Exception as e: + logger.exception("Error setting volume to %s", request.volume) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to set volume", + ) from e + + +@router.post("/mode") +async def set_mode( + request: ModeRequest, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Set playback mode.""" + try: + player = get_player_service() + await player.set_mode(request.mode) + return {"message": f"Mode set to {request.mode.value}"} + except Exception as e: + logger.exception("Error setting mode to %s", request.mode) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to set mode", + ) from e + + +@router.post("/reload-playlist") +async def reload_playlist( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, str]: + """Reload current playlist.""" + try: + player = get_player_service() + await player.reload_playlist() + return {"message": "Playlist reloaded"} + except Exception as e: + logger.exception("Error reloading playlist") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to reload playlist", + ) from e + + +@router.get("/state") +async def get_state( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 +) -> dict[str, Any]: + """Get current player state.""" + try: + player = get_player_service() + return player.get_state() + except Exception as e: + logger.exception("Error getting player state") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get player state", + ) from e \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py index 1d7d200..0472e10 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -38,6 +38,13 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: await session.close() +def get_session_factory(): + """Get a session factory function for services.""" + def session_factory(): + return AsyncSession(engine) + return session_factory + + async def init_db() -> None: """Initialize the database and create tables if they do not exist.""" logger = get_logger(__name__) diff --git a/app/main.py b/app/main.py index f50f055..dc667d3 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api import api_router -from app.core.database import init_db +from app.core.database import get_session_factory, init_db from app.core.logging import get_logger, setup_logging from app.middleware.logging import LoggingMiddleware from app.services.extraction_processor import extraction_processor +from app.services.player import initialize_player_service, shutdown_player_service from app.services.socket import socket_manager @@ -27,10 +28,18 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: await extraction_processor.start() logger.info("Extraction processor started") + # Start the player service + await initialize_player_service(get_session_factory()) + logger.info("Player service started") + yield logger.info("Shutting down application") + # Stop the player service + await shutdown_player_service() + logger.info("Player service stopped") + # Stop the extraction processor await extraction_processor.stop() logger.info("Extraction processor stopped") diff --git a/app/services/extraction.py b/app/services/extraction.py index eafa4a6..cffe830 100644 --- a/app/services/extraction.py +++ b/app/services/extraction.py @@ -158,7 +158,10 @@ class ExtractionService: service_info["service"], service_info["service_id"] ) if existing and existing.id != extraction_id: - error_msg = f"Extraction already exists for {service_info['service']}:{service_info['service_id']}" + error_msg = ( + f"Extraction already exists for " + f"{service_info['service']}:{service_info['service_id']}" + ) logger.warning(error_msg) raise ValueError(error_msg) @@ -204,10 +207,10 @@ class ExtractionService: sound_id = sound.id # Normalize the sound - await self._normalize_sound(sound) + await self._normalize_sound(sound_id) # Add to main playlist - await self._add_to_main_playlist(sound, user_id) + await self._add_to_main_playlist(sound_id, user_id) # Update extraction with success await self.extraction_repo.update( @@ -427,39 +430,45 @@ class ExtractionService: return sound - async def _normalize_sound(self, sound: Sound) -> None: + async def _normalize_sound(self, sound_id: int) -> None: """Normalize the extracted sound.""" try: + # Get fresh sound object from database for normalization + sound = await self.sound_repo.get_by_id(sound_id) + if not sound: + logger.warning("Sound %d not found for normalization", sound_id) + return + normalizer_service = SoundNormalizerService(self.session) result = await normalizer_service.normalize_sound(sound) if result["status"] == "error": logger.warning( "Failed to normalize sound %d: %s", - sound.id, + sound_id, result.get("error"), ) else: - logger.info("Successfully normalized sound %d", sound.id) + logger.info("Successfully normalized sound %d", sound_id) except Exception as e: - logger.exception("Error normalizing sound %d: %s", sound.id, e) + logger.exception("Error normalizing sound %d: %s", sound_id, e) # Don't fail the extraction if normalization fails - async def _add_to_main_playlist(self, sound: Sound, user_id: int) -> None: + async def _add_to_main_playlist(self, sound_id: int, user_id: int) -> None: """Add the sound to the user's main playlist.""" try: - await self.playlist_service.add_sound_to_main_playlist(sound.id, user_id) + await self.playlist_service.add_sound_to_main_playlist(sound_id, user_id) logger.info( "Added sound %d to main playlist for user %d", - sound.id, + sound_id, user_id, ) except Exception: logger.exception( "Error adding sound %d to main playlist for user %d", - sound.id, + sound_id, user_id, ) # Don't fail the extraction if playlist addition fails diff --git a/app/services/player.py b/app/services/player.py new file mode 100644 index 0000000..a42c8b4 --- /dev/null +++ b/app/services/player.py @@ -0,0 +1,637 @@ +"""Player service for audio playbook management.""" + +import asyncio +import threading +import time +from collections.abc import Callable, Coroutine +from enum import Enum +from pathlib import Path +from typing import Any + +import vlc # type: ignore[import-untyped] +from sqlmodel import select + +from app.core.logging import get_logger +from app.models.sound import Sound +from app.models.sound_played import SoundPlayed +from app.repositories.playlist import PlaylistRepository +from app.repositories.sound import SoundRepository +from app.repositories.user import UserRepository +from app.services.socket import socket_manager + +logger = get_logger(__name__) + + +class PlayerStatus(str, Enum): + """Player status enumeration.""" + + STOPPED = "stopped" + PLAYING = "playing" + PAUSED = "paused" + + +class PlayerMode(str, Enum): + """Player mode enumeration.""" + + CONTINUOUS = "continuous" + LOOP = "loop" + LOOP_ONE = "loop_one" + RANDOM = "random" + SINGLE = "single" + + +class PlayerState: + """Player state data structure.""" + + def __init__(self) -> None: + """Initialize player state.""" + self.status: PlayerStatus = PlayerStatus.STOPPED + self.mode: PlayerMode = PlayerMode.CONTINUOUS + self.volume: int = 50 + self.current_sound_id: int | None = None + self.current_sound_index: int | None = None + self.current_sound_position: int = 0 + self.current_sound_duration: int = 0 + self.current_sound: Sound | None = None + self.playlist_id: int | None = None + self.playlist_name: str = "" + self.playlist_length: int = 0 + self.playlist_duration: int = 0 + self.playlist_sounds: list[Sound] = [] + + def to_dict(self) -> dict[str, Any]: + """Convert player state to dictionary for serialization.""" + return { + "status": self.status.value, + "mode": self.mode.value, + "volume": self.volume, + "current_sound_id": self.current_sound_id, + "current_sound_index": self.current_sound_index, + "current_sound_position": self.current_sound_position, + "current_sound_duration": self.current_sound_duration, + "current_sound": self._serialize_sound(self.current_sound), + "playlist_id": self.playlist_id, + "playlist_name": self.playlist_name, + "playlist_length": self.playlist_length, + "playlist_duration": self.playlist_duration, + "playlist_sounds": [ + self._serialize_sound(sound) for sound in self.playlist_sounds + ], + } + + def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None: + """Serialize a sound object for JSON serialization.""" + if not sound: + return None + return { + "id": sound.id, + "name": sound.name, + "filename": sound.filename, + "duration": sound.duration, + "size": sound.size, + "type": sound.type, + "thumbnail": sound.thumbnail, + "play_count": sound.play_count, + } + + +class PlayerService: + """Service for audio playback management.""" + + def __init__(self, db_session_factory: Callable) -> None: + """Initialize the player service.""" + self.db_session_factory = db_session_factory + self.state = PlayerState() + self._vlc_instance = vlc.Instance() + self._player = self._vlc_instance.media_player_new() + self._is_running = False + self._position_thread: threading.Thread | None = None + self._play_time_tracking: dict[int, dict[str, Any]] = {} + self._lock = threading.Lock() + self._background_tasks: set[asyncio.Task] = set() + self._loop: asyncio.AbstractEventLoop | None = None + self._last_position_broadcast: float = 0 + + async def start(self) -> None: + """Start the player service.""" + logger.info("Starting player service") + self._is_running = True + + # Store the event loop for thread-safe task scheduling + self._loop = asyncio.get_running_loop() + + # Load initial playlist + await self.reload_playlist() + + # Start position tracking thread + self._position_thread = threading.Thread( + target=self._position_tracker, daemon=True, + ) + self._position_thread.start() + + # Set initial volume + self._player.audio_set_volume(self.state.volume) + + logger.info("Player service started") + + async def stop(self) -> None: + """Stop the player service.""" + logger.info("Stopping player service") + self._is_running = False + + # Stop playback + await self._stop_playback() + + # Wait for position thread to finish + if self._position_thread and self._position_thread.is_alive(): + self._position_thread.join(timeout=2.0) + + # Release VLC player + self._player.release() + + logger.info("Player service stopped") + + async def play(self, index: int | None = None) -> None: + """Play audio at specified index or current position.""" + # Check if we're resuming from pause + is_resuming = ( + index is None and + self.state.status == PlayerStatus.PAUSED and + self.state.current_sound is not None + ) + + if is_resuming: + # Simply resume playback + result = self._player.play() + if result == 0: # VLC returns 0 on success + self.state.status = PlayerStatus.PLAYING + + # Ensure play time tracking is initialized for resumed track + if ( + self.state.current_sound_id + and self.state.current_sound_id not in self._play_time_tracking + ): + self._play_time_tracking[self.state.current_sound_id] = { + "total_time": 0, + "last_position": self.state.current_sound_position, + "last_update": time.time(), + "threshold_reached": False, + } + + await self._broadcast_state() + logger.info("Resumed playing sound: %s", self.state.current_sound.name) + else: + logger.error("Failed to resume playback: VLC error code %s", result) + return + + # Starting new track or changing track + if index is not None: + if index < 0 or index >= len(self.state.playlist_sounds): + msg = "Invalid sound index" + raise ValueError(msg) + self.state.current_sound_index = index + self.state.current_sound = self.state.playlist_sounds[index] + self.state.current_sound_id = self.state.current_sound.id + + if not self.state.current_sound: + logger.warning("No sound to play") + return + + # Get sound file path + sound_path = self._get_sound_file_path(self.state.current_sound) + if not sound_path.exists(): + logger.error("Sound file not found: %s", sound_path) + return + + # Load and play media (new track) + media = self._vlc_instance.media_new(str(sound_path)) + self._player.set_media(media) + + result = self._player.play() + if result == 0: # VLC returns 0 on success + self.state.status = PlayerStatus.PLAYING + self.state.current_sound_duration = self.state.current_sound.duration or 0 + + # Initialize play time tracking for new track + if self.state.current_sound_id: + self._play_time_tracking[self.state.current_sound_id] = { + "total_time": 0, + "last_position": 0, + "last_update": time.time(), + "threshold_reached": False, + } + logger.info( + "Initialized play time tracking for sound %s (duration: %s ms)", + self.state.current_sound_id, + self.state.current_sound_duration, + ) + + await self._broadcast_state() + logger.info("Started playing sound: %s", self.state.current_sound.name) + else: + logger.error("Failed to start playback: VLC error code %s", result) + + async def pause(self) -> None: + """Pause playback.""" + if self.state.status == PlayerStatus.PLAYING: + self._player.pause() + self.state.status = PlayerStatus.PAUSED + await self._broadcast_state() + logger.info("Playback paused") + + async def stop_playback(self) -> None: + """Stop playback.""" + await self._stop_playback() + await self._broadcast_state() + + async def _stop_playback(self) -> None: + """Stop playback internal method.""" + if self.state.status != PlayerStatus.STOPPED: + self._player.stop() + self.state.status = PlayerStatus.STOPPED + self.state.current_sound_position = 0 + + # Process any pending play counts + await self._process_play_count() + + logger.info("Playback stopped") + + async def next(self) -> None: + """Skip to next track.""" + if not self.state.playlist_sounds: + return + + current_index = self.state.current_sound_index or 0 + next_index = self._get_next_index(current_index) + + if next_index is not None: + await self.play(next_index) + else: + await self._stop_playback() + await self._broadcast_state() + + async def previous(self) -> None: + """Go to previous track.""" + if not self.state.playlist_sounds: + return + + current_index = self.state.current_sound_index or 0 + prev_index = self._get_previous_index(current_index) + + if prev_index is not None: + await self.play(prev_index) + + async def seek(self, position_ms: int) -> None: + """Seek to specific position in current track.""" + if self.state.status == PlayerStatus.STOPPED: + return + + # Convert milliseconds to VLC position (0.0 to 1.0) + if self.state.current_sound_duration > 0: + position = position_ms / self.state.current_sound_duration + position = max(0.0, min(1.0, position)) # Clamp to valid range + + self._player.set_position(position) + self.state.current_sound_position = position_ms + + await self._broadcast_state() + logger.debug("Seeked to position: %sms", position_ms) + + async def set_volume(self, volume: int) -> None: + """Set playback volume (0-100).""" + volume = max(0, min(100, volume)) # Clamp to valid range + self.state.volume = volume + self._player.audio_set_volume(volume) + + await self._broadcast_state() + logger.debug("Volume set to: %s", volume) + + async def set_mode(self, mode: PlayerMode) -> None: + """Set playback mode.""" + self.state.mode = mode + await self._broadcast_state() + logger.info("Playback mode set to: %s", mode.value) + + async def reload_playlist(self) -> None: + """Reload current playlist from database.""" + session = self.db_session_factory() + try: + playlist_repo = PlaylistRepository(session) + + # Get main playlist (fallback for now) + current_playlist = await playlist_repo.get_main_playlist() + + if current_playlist and current_playlist.id: + # Load playlist sounds + sounds = await playlist_repo.get_playlist_sounds(current_playlist.id) + + # 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() + + logger.info( + "Loaded playlist: %s (%s sounds)", + current_playlist.name, + len(sounds), + ) + else: + logger.warning("No playlist found to load") + finally: + await session.close() + + await self._broadcast_state() + + def get_state(self) -> dict[str, Any]: + """Get current player state.""" + return self.state.to_dict() + + def _get_sound_file_path(self, sound: Sound) -> Path: + """Get the file path for a sound.""" + # Determine the correct subdirectory based on sound type + subdir = "extracted" if sound.type.upper() == "EXT" else sound.type.lower() + + # Use normalized file if available, otherwise original + if sound.is_normalized and sound.normalized_filename: + return ( + Path("sounds/normalized") + / subdir + / sound.normalized_filename + ) + return ( + Path("sounds/originals") / subdir / sound.filename + ) + + def _get_next_index(self, current_index: int) -> int | None: + """Get next track index based on current mode.""" + if not self.state.playlist_sounds: + return None + + playlist_length = len(self.state.playlist_sounds) + + if self.state.mode == PlayerMode.SINGLE: + return None + if self.state.mode == PlayerMode.LOOP_ONE: + return current_index + if self.state.mode == PlayerMode.RANDOM: + import random # noqa: PLC0415 + + indices = list(range(playlist_length)) + indices.remove(current_index) + return random.choice(indices) if indices else None # noqa: S311 + # CONTINUOUS or LOOP + next_index = current_index + 1 + if next_index >= playlist_length: + return 0 if self.state.mode == PlayerMode.LOOP else None + return next_index + + def _get_previous_index(self, current_index: int) -> int | None: + """Get previous track index.""" + if not self.state.playlist_sounds: + return None + + playlist_length = len(self.state.playlist_sounds) + prev_index = current_index - 1 + + if prev_index < 0: + return ( + playlist_length - 1 + if self.state.mode == PlayerMode.LOOP + else None + ) + return prev_index + + def _position_tracker(self) -> None: + """Background thread to track playback position and handle auto-advance.""" + while self._is_running: + if self.state.status == PlayerStatus.PLAYING: + # Update position + vlc_position = self._player.get_position() + if vlc_position >= 0: # Valid position + self.state.current_sound_position = int( + vlc_position * self.state.current_sound_duration, + ) + + # Check if track finished + player_state = self._player.get_state() + if hasattr(vlc, "State") and player_state == vlc.State.Ended: + # Track finished, handle auto-advance + self._schedule_async_task(self._handle_track_finished()) + + # Update play time tracking + self._update_play_time() + + # Broadcast state every 0.5 seconds while playing + broadcast_interval = 0.5 + current_time = time.time() + if current_time - self._last_position_broadcast >= broadcast_interval: + self._last_position_broadcast = current_time + self._schedule_async_task(self._broadcast_state()) + + time.sleep(0.1) # 100ms update interval + + def _update_play_time(self) -> None: + """Update play time tracking for current sound.""" + if ( + not self.state.current_sound_id + or self.state.status != PlayerStatus.PLAYING + ): + return + + sound_id = self.state.current_sound_id + current_time = time.time() + current_position = self.state.current_sound_position + + with self._lock: + if sound_id in self._play_time_tracking: + tracking = self._play_time_tracking[sound_id] + + # Calculate time elapsed (only if position advanced reasonably) + time_elapsed = current_time - tracking["last_update"] + position_diff = abs(current_position - tracking["last_position"]) + + # Only count if position advanced naturally (not seeking) + max_position_jump = 5000 # 5 seconds in milliseconds + if time_elapsed > 0 and position_diff < max_position_jump: + # Add real time elapsed (converted to ms) + tracking["total_time"] += time_elapsed * 1000 + + tracking["last_position"] = current_position + tracking["last_update"] = current_time + + # Check if 20% threshold reached + play_threshold = 0.2 # 20% of track duration + threshold_time = self.state.current_sound_duration * play_threshold + + if ( + not tracking["threshold_reached"] + and self.state.current_sound_duration > 0 + and tracking["total_time"] >= threshold_time + ): + tracking["threshold_reached"] = True + logger.info( + "Play count threshold reached for sound %s: %s/%s ms (%.1f%%)", + sound_id, + tracking["total_time"], + self.state.current_sound_duration, + ( + tracking["total_time"] + / self.state.current_sound_duration + ) * 100, + ) + self._schedule_async_task(self._record_play_count(sound_id)) + + async def _record_play_count(self, sound_id: int) -> None: + """Record a play count for a sound.""" + logger.info("Recording play count for sound %s", sound_id) + session = self.db_session_factory() + try: + sound_repo = SoundRepository(session) + user_repo = UserRepository(session) + + # Update sound play count + sound = await sound_repo.get_by_id(sound_id) + if sound: + old_count = sound.play_count + await sound_repo.update( + sound, {"play_count": sound.play_count + 1}, + ) + logger.info( + "Updated sound %s play_count: %s -> %s", + sound_id, + old_count, + old_count + 1, + ) + else: + logger.warning("Sound %s not found for play count update", sound_id) + + # Record play history for admin user (ID 1) as placeholder + # This could be refined to track per-user play history + admin_user = await user_repo.get_by_id(1) + if admin_user: + # Check if already recorded for this user using proper query + stmt = select(SoundPlayed).where( + SoundPlayed.user_id == admin_user.id, + SoundPlayed.sound_id == sound_id, + ) + result = await session.exec(stmt) + existing = result.first() + + if not existing: + sound_played = SoundPlayed( + user_id=admin_user.id, + sound_id=sound_id, + ) + session.add(sound_played) + logger.info( + "Created SoundPlayed record for user %s, sound %s", + admin_user.id, + sound_id, + ) + else: + logger.info( + "SoundPlayed record already exists for user %s, sound %s", + admin_user.id, + sound_id, + ) + else: + logger.warning("Admin user (ID 1) not found for play history") + + await session.commit() + logger.info("Successfully recorded play count for sound %s", sound_id) + except Exception: + logger.exception("Error recording play count for sound %s", sound_id) + await session.rollback() + finally: + await session.close() + + async def _process_play_count(self) -> None: + """Process any pending play counts when stopping.""" + if not self.state.current_sound_id: + return + + sound_id = self.state.current_sound_id + with self._lock: + if ( + sound_id in self._play_time_tracking + and self._play_time_tracking[sound_id]["threshold_reached"] + ): + # Already processed + del self._play_time_tracking[sound_id] + + async def _handle_track_finished(self) -> None: + """Handle when a track finishes playing.""" + await self._process_play_count() + + # Auto-advance to next track + if self.state.current_sound_index is not None: + next_index = self._get_next_index(self.state.current_sound_index) + if next_index is not None: + await self.play(next_index) + else: + await self._stop_playback() + await self._broadcast_state() + + async def _broadcast_state(self) -> None: + """Broadcast current player state via WebSocket.""" + try: + state_data = self.get_state() + await socket_manager.broadcast_to_all("player_state", state_data) + except Exception: + logger.exception("Error broadcasting player state") + + def _track_task(self, task: asyncio.Task) -> None: + """Track background task to prevent garbage collection.""" + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _schedule_async_task(self, coro: Coroutine[Any, Any, Any]) -> None: + """Schedule an async task from a background thread.""" + if self._loop and not self._loop.is_closed(): + try: + # Use run_coroutine_threadsafe to schedule the coroutine + asyncio.run_coroutine_threadsafe(coro, self._loop) + # Don't wait for the result to avoid blocking the thread + except Exception: + logger.exception("Error scheduling async task") + + +# Global player service instance +player_service: PlayerService | None = None + + +def get_player_service() -> PlayerService: + """Get the global player service instance.""" + if player_service is None: + msg = "Player service not initialized" + raise RuntimeError(msg) + return player_service + + +async def initialize_player_service(db_session_factory: Callable) -> None: + """Initialize the global player service.""" + global player_service # noqa: PLW0603 + player_service = PlayerService(db_session_factory) + await player_service.start() + + +async def shutdown_player_service() -> None: + """Shutdown the global player service.""" + global player_service # noqa: PLW0603 + if player_service: + await player_service.stop() + player_service = None diff --git a/pyproject.toml b/pyproject.toml index f0ffe70..3fb787e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ dependencies = [ "httpx==0.28.1", "pydantic-settings==2.10.1", "pyjwt==2.10.1", - "python-socketio>=5.13.0", + "python-socketio==5.13.0", + "python-vlc==3.0.21203", "sqlmodel==0.0.24", "uvicorn[standard]==0.35.0", "yt-dlp==2025.7.21", @@ -21,13 +22,13 @@ dependencies = [ [tool.uv] dev-dependencies = [ - "coverage==7.10.0", + "coverage==7.10.1", "faker==37.4.2", "httpx==0.28.1", "mypy==1.17.0", "pytest==8.4.1", "pytest-asyncio==1.1.0", - "ruff==0.12.5", + "ruff==0.12.6", ] [tool.mypy] diff --git a/tests/api/v1/test_player_endpoints.py b/tests/api/v1/test_player_endpoints.py new file mode 100644 index 0000000..6fe39fd --- /dev/null +++ b/tests/api/v1/test_player_endpoints.py @@ -0,0 +1,658 @@ +"""Tests for player API endpoints.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from httpx import AsyncClient + +from app.models.user import User +from app.services.player import PlayerMode, PlayerStatus + + +@pytest.fixture +def mock_player_service(): + """Mock player service for testing.""" + with patch("app.api.v1.player.get_player_service") as mock: + service = Mock() + service.play = AsyncMock() + service.pause = AsyncMock() + service.stop_playback = AsyncMock() + service.next = AsyncMock() + service.previous = AsyncMock() + service.seek = AsyncMock() + service.set_volume = AsyncMock() + service.set_mode = AsyncMock() + service.reload_playlist = AsyncMock() + service.get_state = Mock() # This should return a dict, not a coroutine + mock.return_value = service + yield service + + +class TestPlayerEndpoints: + """Test player API endpoints.""" + + @pytest.mark.asyncio + async def test_play_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test starting playback successfully.""" + response = await authenticated_client.post("/api/v1/player/play") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Playback started" + + mock_player_service.play.assert_called_once_with() + + @pytest.mark.asyncio + async def test_play_unauthenticated(self, client: AsyncClient): + """Test starting playback without authentication.""" + response = await client.post("/api/v1/player/play") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_play_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test starting playback with service error.""" + mock_player_service.play.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/play") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to start playback" + + @pytest.mark.asyncio + async def test_play_at_index_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test playing sound at specific index successfully.""" + index = 2 + response = await authenticated_client.post(f"/api/v1/player/play/{index}") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == f"Playing sound at index {index}" + + mock_player_service.play.assert_called_once_with(index) + + @pytest.mark.asyncio + async def test_play_at_index_invalid_index( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test playing sound with invalid index.""" + mock_player_service.play.side_effect = ValueError("Invalid sound index") + + response = await authenticated_client.post("/api/v1/player/play/999") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Invalid sound index" + + @pytest.mark.asyncio + async def test_play_at_index_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test playing sound at index with service error.""" + mock_player_service.play.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/play/0") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to play sound" + + @pytest.mark.asyncio + async def test_pause_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test pausing playback successfully.""" + response = await authenticated_client.post("/api/v1/player/pause") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Playback paused" + + mock_player_service.pause.assert_called_once() + + @pytest.mark.asyncio + async def test_pause_unauthenticated(self, client: AsyncClient): + """Test pausing playback without authentication.""" + response = await client.post("/api/v1/player/pause") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_pause_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test pausing playback with service error.""" + mock_player_service.pause.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/pause") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to pause playback" + + @pytest.mark.asyncio + async def test_stop_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test stopping playback successfully.""" + response = await authenticated_client.post("/api/v1/player/stop") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Playback stopped" + + mock_player_service.stop_playback.assert_called_once() + + @pytest.mark.asyncio + async def test_stop_unauthenticated(self, client: AsyncClient): + """Test stopping playback without authentication.""" + response = await client.post("/api/v1/player/stop") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_stop_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test stopping playback with service error.""" + mock_player_service.stop_playback.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/stop") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to stop playback" + + @pytest.mark.asyncio + async def test_next_track_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test skipping to next track successfully.""" + response = await authenticated_client.post("/api/v1/player/next") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Skipped to next track" + + mock_player_service.next.assert_called_once() + + @pytest.mark.asyncio + async def test_next_track_unauthenticated(self, client: AsyncClient): + """Test skipping to next track without authentication.""" + response = await client.post("/api/v1/player/next") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_next_track_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test skipping to next track with service error.""" + mock_player_service.next.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/next") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to skip to next track" + + @pytest.mark.asyncio + async def test_previous_track_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test going to previous track successfully.""" + response = await authenticated_client.post("/api/v1/player/previous") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Went to previous track" + + mock_player_service.previous.assert_called_once() + + @pytest.mark.asyncio + async def test_previous_track_unauthenticated(self, client: AsyncClient): + """Test going to previous track without authentication.""" + response = await client.post("/api/v1/player/previous") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_previous_track_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test going to previous track with service error.""" + mock_player_service.previous.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/previous") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to go to previous track" + + @pytest.mark.asyncio + async def test_seek_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test seeking to position successfully.""" + position_ms = 5000 + response = await authenticated_client.post( + "/api/v1/player/seek", + json={"position_ms": position_ms}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == f"Seeked to position {position_ms}ms" + + mock_player_service.seek.assert_called_once_with(position_ms) + + @pytest.mark.asyncio + async def test_seek_invalid_position( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test seeking with invalid position.""" + response = await authenticated_client.post( + "/api/v1/player/seek", + json={"position_ms": -1000}, # Negative position + ) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + async def test_seek_unauthenticated(self, client: AsyncClient): + """Test seeking without authentication.""" + response = await client.post( + "/api/v1/player/seek", + json={"position_ms": 5000}, + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_seek_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test seeking with service error.""" + mock_player_service.seek.side_effect = Exception("Service error") + + response = await authenticated_client.post( + "/api/v1/player/seek", + json={"position_ms": 5000}, + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to seek" + + @pytest.mark.asyncio + async def test_set_volume_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting volume successfully.""" + volume = 75 + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": volume}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == f"Volume set to {volume}" + + mock_player_service.set_volume.assert_called_once_with(volume) + + @pytest.mark.asyncio + async def test_set_volume_invalid_range( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting volume with invalid range.""" + # Test volume too high + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": 150}, + ) + assert response.status_code == 422 + + # Test volume too low + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": -10}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_set_volume_unauthenticated(self, client: AsyncClient): + """Test setting volume without authentication.""" + response = await client.post( + "/api/v1/player/volume", + json={"volume": 50}, + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_set_volume_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting volume with service error.""" + mock_player_service.set_volume.side_effect = Exception("Service error") + + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": 75}, + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to set volume" + + @pytest.mark.asyncio + async def test_set_mode_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting playback mode successfully.""" + mode = PlayerMode.LOOP + response = await authenticated_client.post( + "/api/v1/player/mode", + json={"mode": mode.value}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == f"Mode set to {mode.value}" + + mock_player_service.set_mode.assert_called_once_with(mode) + + @pytest.mark.asyncio + async def test_set_mode_invalid_mode( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting invalid playback mode.""" + response = await authenticated_client.post( + "/api/v1/player/mode", + json={"mode": "invalid_mode"}, + ) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + async def test_set_mode_unauthenticated(self, client: AsyncClient): + """Test setting mode without authentication.""" + response = await client.post( + "/api/v1/player/mode", + json={"mode": "loop"}, + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_set_mode_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting mode with service error.""" + mock_player_service.set_mode.side_effect = Exception("Service error") + + response = await authenticated_client.post( + "/api/v1/player/mode", + json={"mode": "loop"}, + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to set mode" + + @pytest.mark.asyncio + async def test_reload_playlist_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test reloading playlist successfully.""" + response = await authenticated_client.post("/api/v1/player/reload-playlist") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Playlist reloaded" + + mock_player_service.reload_playlist.assert_called_once() + + @pytest.mark.asyncio + async def test_reload_playlist_unauthenticated(self, client: AsyncClient): + """Test reloading playlist without authentication.""" + response = await client.post("/api/v1/player/reload-playlist") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_reload_playlist_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test reloading playlist with service error.""" + mock_player_service.reload_playlist.side_effect = Exception("Service error") + + response = await authenticated_client.post("/api/v1/player/reload-playlist") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to reload playlist" + + @pytest.mark.asyncio + async def test_get_state_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test getting player state successfully.""" + mock_state = { + "status": PlayerStatus.PLAYING.value, + "mode": PlayerMode.CONTINUOUS.value, + "volume": 50, + "current_sound_id": 1, + "current_sound_index": 0, + "current_sound_position": 5000, + "current_sound_duration": 30000, + "current_sound": { + "id": 1, + "name": "Test Song", + "filename": "test.mp3", + "duration": 30000, + "size": 1024, + "type": "SDB", + "thumbnail": None, + "play_count": 0, + }, + "playlist_id": 1, + "playlist_name": "Test Playlist", + "playlist_length": 1, + "playlist_duration": 30000, + "playlist_sounds": [], + } + mock_player_service.get_state.return_value = mock_state + + response = await authenticated_client.get("/api/v1/player/state") + + assert response.status_code == 200 + data = response.json() + assert data == mock_state + + mock_player_service.get_state.assert_called_once() + + @pytest.mark.asyncio + async def test_get_state_unauthenticated(self, client: AsyncClient): + """Test getting player state without authentication.""" + response = await client.get("/api/v1/player/state") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_get_state_service_error( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test getting player state with service error.""" + mock_player_service.get_state.side_effect = Exception("Service error") + + response = await authenticated_client.get("/api/v1/player/state") + + assert response.status_code == 500 + data = response.json() + assert data["detail"] == "Failed to get player state" + + @pytest.mark.asyncio + async def test_seek_missing_body( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + ): + """Test seeking without request body.""" + response = await authenticated_client.post("/api/v1/player/seek") + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_volume_missing_body( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + ): + """Test setting volume without request body.""" + response = await authenticated_client.post("/api/v1/player/volume") + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_mode_missing_body( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + ): + """Test setting mode without request body.""" + response = await authenticated_client.post("/api/v1/player/mode") + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_play_at_index_negative_index( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test playing sound with negative index.""" + mock_player_service.play.side_effect = ValueError("Invalid sound index") + + response = await authenticated_client.post("/api/v1/player/play/-1") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Invalid sound index" + + @pytest.mark.asyncio + async def test_seek_zero_position( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test seeking to position zero.""" + response = await authenticated_client.post( + "/api/v1/player/seek", + json={"position_ms": 0}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Seeked to position 0ms" + + mock_player_service.seek.assert_called_once_with(0) + + @pytest.mark.asyncio + async def test_set_volume_boundary_values( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + mock_player_service, + ): + """Test setting volume with boundary values.""" + # Test minimum volume + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": 0}, + ) + assert response.status_code == 200 + mock_player_service.set_volume.assert_called_with(0) + + # Test maximum volume + response = await authenticated_client.post( + "/api/v1/player/volume", + json={"volume": 100}, + ) + assert response.status_code == 200 + mock_player_service.set_volume.assert_called_with(100) \ No newline at end of file diff --git a/tests/services/test_extraction.py b/tests/services/test_extraction.py index 74af72f..367b702 100644 --- a/tests/services/test_extraction.py +++ b/tests/services/test_extraction.py @@ -329,6 +329,10 @@ class TestExtractionService: hash="test_hash", is_normalized=False, ) + sound_id = 1 + + # Mock sound repository to return the sound + extraction_service.sound_repo.get_by_id = AsyncMock(return_value=sound) mock_normalizer = Mock() mock_normalizer.normalize_sound = AsyncMock( @@ -340,7 +344,8 @@ class TestExtractionService: return_value=mock_normalizer, ): # Should not raise exception - await extraction_service._normalize_sound(sound) + await extraction_service._normalize_sound(sound_id) + extraction_service.sound_repo.get_by_id.assert_called_once_with(sound_id) mock_normalizer.normalize_sound.assert_called_once_with(sound) @pytest.mark.asyncio @@ -356,6 +361,10 @@ class TestExtractionService: hash="test_hash", is_normalized=False, ) + sound_id = 1 + + # Mock sound repository to return the sound + extraction_service.sound_repo.get_by_id = AsyncMock(return_value=sound) mock_normalizer = Mock() mock_normalizer.normalize_sound = AsyncMock( @@ -367,7 +376,8 @@ class TestExtractionService: return_value=mock_normalizer, ): # Should not raise exception even on failure - await extraction_service._normalize_sound(sound) + await extraction_service._normalize_sound(sound_id) + extraction_service.sound_repo.get_by_id.assert_called_once_with(sound_id) mock_normalizer.normalize_sound.assert_called_once_with(sound) @pytest.mark.asyncio diff --git a/tests/services/test_player.py b/tests/services/test_player.py new file mode 100644 index 0000000..405fc7c --- /dev/null +++ b/tests/services/test_player.py @@ -0,0 +1,623 @@ +"""Tests for player service.""" + +import asyncio +import threading +import time +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.sound import Sound +from app.models.sound_played import SoundPlayed +from app.models.user import User +from app.services.player import ( + PlayerMode, + PlayerService, + PlayerState, + PlayerStatus, + get_player_service, + initialize_player_service, + shutdown_player_service, +) + + +class TestPlayerState: + """Test player state data structure.""" + + def test_init_creates_default_state(self): + """Test that player state initializes with default values.""" + state = PlayerState() + + assert state.status == PlayerStatus.STOPPED + assert state.mode == PlayerMode.CONTINUOUS + assert state.volume == 50 + assert state.current_sound_id is None + assert state.current_sound_index is None + assert state.current_sound_position == 0 + assert state.current_sound_duration == 0 + assert state.current_sound is None + assert state.playlist_id is None + assert state.playlist_name == "" + assert state.playlist_length == 0 + assert state.playlist_duration == 0 + assert state.playlist_sounds == [] + + def test_to_dict_serializes_correctly(self): + """Test that player state serializes to dict correctly.""" + state = PlayerState() + state.status = PlayerStatus.PLAYING + state.mode = PlayerMode.LOOP + state.volume = 75 + state.current_sound_id = 1 + state.current_sound_index = 0 + state.current_sound_position = 5000 + state.current_sound_duration = 30000 + state.playlist_id = 1 + state.playlist_name = "Test Playlist" + state.playlist_length = 5 + state.playlist_duration = 150000 + + result = state.to_dict() + + assert result["status"] == "playing" + assert result["mode"] == "loop" + assert result["volume"] == 75 + assert result["current_sound_id"] == 1 + assert result["current_sound_index"] == 0 + assert result["current_sound_position"] == 5000 + assert result["current_sound_duration"] == 30000 + assert result["playlist_id"] == 1 + assert result["playlist_name"] == "Test Playlist" + assert result["playlist_length"] == 5 + assert result["playlist_duration"] == 150000 + + def test_serialize_sound_with_sound_object(self): + """Test serializing a sound object.""" + state = PlayerState() + sound = Sound( + id=1, + name="Test Song", + filename="test.mp3", + duration=30000, + size=1024, + type="SDB", + thumbnail="test.jpg", + play_count=5, + ) + + result = state._serialize_sound(sound) + + assert result["id"] == 1 + assert result["name"] == "Test Song" + assert result["filename"] == "test.mp3" + assert result["duration"] == 30000 + assert result["size"] == 1024 + assert result["type"] == "SDB" + assert result["thumbnail"] == "test.jpg" + assert result["play_count"] == 5 + + def test_serialize_sound_with_none(self): + """Test serializing None sound.""" + state = PlayerState() + result = state._serialize_sound(None) + assert result is None + + +class TestPlayerService: + """Test player service functionality.""" + + @pytest.fixture + def mock_db_session_factory(self): + """Create a mock database session factory.""" + session = AsyncMock(spec=AsyncSession) + return lambda: session + + @pytest.fixture + def mock_vlc_instance(self): + """Create a mock VLC instance.""" + with patch("app.services.player.vlc") as mock_vlc: + mock_instance = Mock() + mock_player = Mock() + mock_vlc.Instance.return_value = mock_instance + mock_instance.media_player_new.return_value = mock_player + mock_vlc.State = Mock() + mock_vlc.State.Ended = "ended" + yield mock_vlc + + @pytest.fixture + def mock_socket_manager(self): + """Create a mock socket manager.""" + with patch("app.services.player.socket_manager") as mock: + mock.broadcast_to_all = AsyncMock() + yield mock + + @pytest.fixture + def player_service(self, mock_db_session_factory, mock_vlc_instance, mock_socket_manager): + """Create a player service instance for testing.""" + service = PlayerService(mock_db_session_factory) + return service + + def test_init_creates_player_service(self, mock_db_session_factory, mock_vlc_instance): + """Test that player service initializes correctly.""" + with patch("app.services.player.socket_manager"): + service = PlayerService(mock_db_session_factory) + + assert service.db_session_factory is mock_db_session_factory + assert isinstance(service.state, PlayerState) + assert service._vlc_instance is not None + assert service._player is not None + assert service._is_running is False + assert service._position_thread is None + assert service._play_time_tracking == {} + assert isinstance(service._lock, type(threading.Lock())) + assert service._background_tasks == set() + assert service._loop is None + + @pytest.mark.asyncio + async def test_start_initializes_service(self, player_service, mock_vlc_instance): + """Test that start method initializes the service.""" + with patch.object(player_service, "reload_playlist", new_callable=AsyncMock): + await player_service.start() + + assert player_service._is_running is True + assert player_service._loop is not None + assert player_service._position_thread is not None + assert player_service._position_thread.daemon is True + player_service._player.audio_set_volume.assert_called_once_with(50) + + @pytest.mark.asyncio + async def test_stop_cleans_up_service(self, player_service): + """Test that stop method cleans up the service.""" + # Setup initial state + player_service._is_running = True + player_service._position_thread = Mock() + player_service._position_thread.is_alive.return_value = True + + with patch.object(player_service, "_stop_playback", new_callable=AsyncMock): + await player_service.stop() + + assert player_service._is_running is False + player_service._position_thread.join.assert_called_once_with(timeout=2.0) + player_service._player.release.assert_called_once() + + @pytest.mark.asyncio + async def test_play_new_track(self, player_service, mock_vlc_instance): + """Test playing a new track.""" + # Setup test sound + sound = Sound( + id=1, + name="Test Song", + filename="test.mp3", + duration=30000, + size=1024, + type="SDB", + ) + player_service.state.playlist_sounds = [sound] + + with patch.object(player_service, "_get_sound_file_path") as mock_path: + mock_file_path = Mock(spec=Path) + mock_file_path.exists.return_value = True + mock_path.return_value = mock_file_path + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + mock_media = Mock() + player_service._vlc_instance.media_new.return_value = mock_media + player_service._player.play.return_value = 0 # Success + + await player_service.play(0) + + assert player_service.state.status == PlayerStatus.PLAYING + assert player_service.state.current_sound == sound + assert player_service.state.current_sound_id == 1 + assert player_service.state.current_sound_index == 0 + assert 1 in player_service._play_time_tracking + + @pytest.mark.asyncio + async def test_play_resume_from_pause(self, player_service): + """Test resuming playback from pause.""" + # Setup paused state + sound = Sound(id=1, name="Test Song", filename="test.mp3", duration=30000) + player_service.state.status = PlayerStatus.PAUSED + player_service.state.current_sound = sound + player_service.state.current_sound_id = 1 + player_service.state.current_sound_position = 5000 + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + player_service._player.play.return_value = 0 # Success + + await player_service.play() + + assert player_service.state.status == PlayerStatus.PLAYING + player_service._player.play.assert_called_once() + + @pytest.mark.asyncio + async def test_play_invalid_index(self, player_service): + """Test playing with invalid index raises ValueError.""" + player_service.state.playlist_sounds = [] + + with pytest.raises(ValueError, match="Invalid sound index"): + await player_service.play(0) + + @pytest.mark.asyncio + async def test_pause_when_playing(self, player_service): + """Test pausing when currently playing.""" + player_service.state.status = PlayerStatus.PLAYING + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.pause() + + assert player_service.state.status == PlayerStatus.PAUSED + player_service._player.pause.assert_called_once() + + @pytest.mark.asyncio + async def test_pause_when_not_playing(self, player_service): + """Test pausing when not playing does nothing.""" + player_service.state.status = PlayerStatus.STOPPED + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock) as mock_broadcast: + await player_service.pause() + + assert player_service.state.status == PlayerStatus.STOPPED + mock_broadcast.assert_not_called() + + @pytest.mark.asyncio + async def test_stop_playback(self, player_service): + """Test stopping playback.""" + player_service.state.status = PlayerStatus.PLAYING + player_service.state.current_sound_position = 5000 + + with patch.object(player_service, "_process_play_count", new_callable=AsyncMock): + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.stop_playback() + + assert player_service.state.status == PlayerStatus.STOPPED + assert player_service.state.current_sound_position == 0 + player_service._player.stop.assert_called_once() + + @pytest.mark.asyncio + async def test_next_track(self, player_service): + """Test skipping to next track.""" + sound1 = Sound(id=1, name="Song 1", filename="song1.mp3") + sound2 = Sound(id=2, name="Song 2", filename="song2.mp3") + player_service.state.playlist_sounds = [sound1, sound2] + player_service.state.current_sound_index = 0 + + with patch.object(player_service, "play", new_callable=AsyncMock) as mock_play: + await player_service.next() + mock_play.assert_called_once_with(1) + + @pytest.mark.asyncio + async def test_previous_track(self, player_service): + """Test going to previous track.""" + sound1 = Sound(id=1, name="Song 1", filename="song1.mp3") + sound2 = Sound(id=2, name="Song 2", filename="song2.mp3") + player_service.state.playlist_sounds = [sound1, sound2] + player_service.state.current_sound_index = 1 + + with patch.object(player_service, "play", new_callable=AsyncMock) as mock_play: + await player_service.previous() + mock_play.assert_called_once_with(0) + + @pytest.mark.asyncio + async def test_seek_position(self, player_service): + """Test seeking to specific position.""" + player_service.state.status = PlayerStatus.PLAYING + player_service.state.current_sound_duration = 30000 + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.seek(15000) + + # Position should be 0.5 (50% of track) + player_service._player.set_position.assert_called_once_with(0.5) + assert player_service.state.current_sound_position == 15000 + + @pytest.mark.asyncio + async def test_seek_when_stopped(self, player_service): + """Test seeking when stopped does nothing.""" + player_service.state.status = PlayerStatus.STOPPED + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock) as mock_broadcast: + await player_service.seek(15000) + + player_service._player.set_position.assert_not_called() + mock_broadcast.assert_not_called() + + @pytest.mark.asyncio + async def test_set_volume(self, player_service): + """Test setting volume.""" + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.set_volume(75) + + assert player_service.state.volume == 75 + player_service._player.audio_set_volume.assert_called_once_with(75) + + @pytest.mark.asyncio + async def test_set_volume_clamping(self, player_service): + """Test volume clamping to valid range.""" + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + # Test upper bound + await player_service.set_volume(150) + assert player_service.state.volume == 100 + + # Test lower bound + await player_service.set_volume(-10) + assert player_service.state.volume == 0 + + @pytest.mark.asyncio + async def test_set_mode(self, player_service): + """Test setting playback mode.""" + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.set_mode(PlayerMode.LOOP) + + assert player_service.state.mode == PlayerMode.LOOP + + @pytest.mark.asyncio + async def test_reload_playlist(self, player_service): + """Test reloading playlist from database.""" + mock_session = AsyncMock() + player_service.db_session_factory = lambda: mock_session + + # Mock playlist repository + with patch("app.services.player.PlaylistRepository") as mock_repo_class: + mock_repo = AsyncMock() + mock_repo_class.return_value = mock_repo + + # Mock playlist data + mock_playlist = Mock() + mock_playlist.id = 1 + mock_playlist.name = "Test Playlist" + mock_repo.get_main_playlist.return_value = mock_playlist + + # Mock 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) + mock_sounds = [sound1, sound2] + mock_repo.get_playlist_sounds.return_value = mock_sounds + + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + await player_service.reload_playlist() + + assert player_service.state.playlist_id == 1 + assert player_service.state.playlist_name == "Test Playlist" + assert player_service.state.playlist_sounds == mock_sounds + assert player_service.state.playlist_length == 2 + assert player_service.state.playlist_duration == 75000 + + def test_get_sound_file_path_normalized(self, player_service): + """Test getting file path for normalized sound.""" + sound = Sound( + id=1, + name="Test Song", + filename="original.mp3", + normalized_filename="normalized.mp3", + is_normalized=True, + type="SDB", + ) + + result = player_service._get_sound_file_path(sound) + + expected = Path("sounds/normalized/sdb/normalized.mp3") + assert result == expected + + def test_get_sound_file_path_original(self, player_service): + """Test getting file path for original sound.""" + sound = Sound( + id=1, + name="Test Song", + filename="original.mp3", + is_normalized=False, + type="SDB", + ) + + result = player_service._get_sound_file_path(sound) + + expected = Path("sounds/originals/sdb/original.mp3") + assert result == expected + + def test_get_sound_file_path_ext_type(self, player_service): + """Test getting file path for EXT type sound.""" + sound = Sound( + id=1, + name="Test Song", + filename="extracted.mp3", + is_normalized=False, + type="EXT", + ) + + result = player_service._get_sound_file_path(sound) + + expected = Path("sounds/originals/extracted/extracted.mp3") + assert result == expected + + def test_get_next_index_continuous_mode(self, player_service): + """Test getting next index in continuous mode.""" + player_service.state.mode = PlayerMode.CONTINUOUS + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + # Test normal progression + assert player_service._get_next_index(0) == 1 + assert player_service._get_next_index(1) == 2 + + # Test end of playlist + assert player_service._get_next_index(2) is None + + def test_get_next_index_loop_mode(self, player_service): + """Test getting next index in loop mode.""" + player_service.state.mode = PlayerMode.LOOP + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + # Test normal progression + assert player_service._get_next_index(0) == 1 + assert player_service._get_next_index(1) == 2 + + # Test wrapping to beginning + assert player_service._get_next_index(2) == 0 + + def test_get_next_index_loop_one_mode(self, player_service): + """Test getting next index in loop one mode.""" + player_service.state.mode = PlayerMode.LOOP_ONE + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + # Should always return same index + assert player_service._get_next_index(0) == 0 + assert player_service._get_next_index(1) == 1 + assert player_service._get_next_index(2) == 2 + + def test_get_next_index_single_mode(self, player_service): + """Test getting next index in single mode.""" + player_service.state.mode = PlayerMode.SINGLE + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + # Should always return None + assert player_service._get_next_index(0) is None + assert player_service._get_next_index(1) is None + assert player_service._get_next_index(2) is None + + def test_get_next_index_random_mode(self, player_service): + """Test getting next index in random mode.""" + player_service.state.mode = PlayerMode.RANDOM + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + with patch("random.choice") as mock_choice: + mock_choice.return_value = 2 + + result = player_service._get_next_index(0) + + assert result == 2 + # Should exclude current index + mock_choice.assert_called_once_with([1, 2]) + + def test_get_previous_index(self, player_service): + """Test getting previous index.""" + player_service.state.playlist_sounds = [Mock(), Mock(), Mock()] + + # Test normal progression + assert player_service._get_previous_index(2) == 1 + assert player_service._get_previous_index(1) == 0 + + # Test beginning without loop + player_service.state.mode = PlayerMode.CONTINUOUS + assert player_service._get_previous_index(0) is None + + # Test beginning with loop + player_service.state.mode = PlayerMode.LOOP + assert player_service._get_previous_index(0) == 2 + + def test_update_play_time(self, player_service): + """Test updating play time tracking.""" + # Setup state + player_service.state.status = PlayerStatus.PLAYING + player_service.state.current_sound_id = 1 + player_service.state.current_sound_position = 5000 + player_service.state.current_sound_duration = 30000 + + # Initialize tracking + current_time = time.time() + player_service._play_time_tracking[1] = { + "total_time": 1000, + "last_position": 4000, + "last_update": current_time - 1.0, # 1 second ago + "threshold_reached": False, + } + + with patch("app.services.player.time.time", return_value=current_time): + player_service._update_play_time() + + tracking = player_service._play_time_tracking[1] + assert tracking["total_time"] == 2000 # Added 1 second (1000ms) + assert tracking["last_position"] == 5000 + assert tracking["last_update"] == current_time + + @pytest.mark.asyncio + async def test_record_play_count(self, player_service): + """Test recording play count for a sound.""" + mock_session = AsyncMock() + player_service.db_session_factory = lambda: mock_session + + # Mock repositories + with patch("app.services.player.SoundRepository") as mock_sound_repo_class: + with patch("app.services.player.UserRepository") as mock_user_repo_class: + mock_sound_repo = AsyncMock() + mock_user_repo = AsyncMock() + mock_sound_repo_class.return_value = mock_sound_repo + mock_user_repo_class.return_value = mock_user_repo + + # Mock sound and user + mock_sound = Mock() + mock_sound.play_count = 5 + mock_sound_repo.get_by_id.return_value = mock_sound + + mock_user = Mock() + mock_user.id = 1 + mock_user_repo.get_by_id.return_value = mock_user + + # Mock no existing SoundPlayed record + mock_result = Mock() + mock_result.first.return_value = None + mock_session.exec.return_value = mock_result + + await player_service._record_play_count(1) + + # Verify sound play count was updated + mock_sound_repo.update.assert_called_once_with( + mock_sound, {"play_count": 6} + ) + + # Verify SoundPlayed record was created + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + def test_get_state(self, player_service): + """Test getting current player state.""" + result = player_service.get_state() + assert isinstance(result, dict) + assert "status" in result + assert "mode" in result + assert "volume" in result + + +class TestPlayerServiceGlobalFunctions: + """Test global player service functions.""" + + @pytest.mark.asyncio + async def test_initialize_player_service(self): + """Test initializing global player service.""" + mock_factory = Mock() + + with patch("app.services.player.PlayerService") as mock_service_class: + mock_service = AsyncMock() + mock_service_class.return_value = mock_service + + await initialize_player_service(mock_factory) + + mock_service_class.assert_called_once_with(mock_factory) + mock_service.start.assert_called_once() + + @pytest.mark.asyncio + async def test_shutdown_player_service(self): + """Test shutting down global player service.""" + # Mock global player service exists + with patch("app.services.player.player_service") as mock_global: + mock_service = AsyncMock() + mock_global.__bool__ = lambda x: True # Service exists + type(mock_global).__bool__ = lambda x: True + + with patch("app.services.player.player_service", mock_service): + await shutdown_player_service() + mock_service.stop.assert_called_once() + + def test_get_player_service_success(self): + """Test getting player service when initialized.""" + mock_service = Mock() + + with patch("app.services.player.player_service", mock_service): + result = get_player_service() + assert result is mock_service + + def test_get_player_service_not_initialized(self): + """Test getting player service when not initialized.""" + with patch("app.services.player.player_service", None): + with pytest.raises(RuntimeError, match="Player service not initialized"): + get_player_service() \ No newline at end of file diff --git a/uv.lock b/uv.lock index c8847e9..4017260 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-socketio" }, + { name = "python-vlc" }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, { name = "yt-dlp" }, @@ -77,7 +78,8 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "pydantic-settings", specifier = "==2.10.1" }, { name = "pyjwt", specifier = "==2.10.1" }, - { name = "python-socketio", specifier = ">=5.13.0" }, + { name = "python-socketio", specifier = "==5.13.0" }, + { name = "python-vlc", specifier = "==3.0.21203" }, { name = "sqlmodel", specifier = "==0.0.24" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, { name = "yt-dlp", specifier = "==2025.7.21" }, @@ -85,13 +87,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "coverage", specifier = "==7.10.0" }, + { name = "coverage", specifier = "==7.10.1" }, { name = "faker", specifier = "==37.4.2" }, { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==1.17.0" }, { name = "pytest", specifier = "==8.4.1" }, { name = "pytest-asyncio", specifier = "==1.1.0" }, - { name = "ruff", specifier = "==0.12.5" }, + { name = "ruff", specifier = "==0.12.6" }, ] [[package]] @@ -185,66 +187,66 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.0" +version = "7.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/8f/6ac7fbb29e35645065f7be835bfe3e0cce567f80390de2f3db65d83cb5e3/coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867", size = 819816 } +sdist = { url = "https://files.pythonhosted.org/packages/87/0e/66dbd4c6a7f0758a8d18044c048779ba21fb94856e1edcf764bd5403e710/coverage-7.10.1.tar.gz", hash = "sha256:ae2b4856f29ddfe827106794f3589949a57da6f0d38ab01e24ec35107979ba57", size = 819938 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b4/7b419bb368c9f0b88889cb24805164f6e5550d7183fb59524f6173e0cf0b/coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df", size = 215124 }, - { url = "https://files.pythonhosted.org/packages/f4/15/d862a806734c7e50fd5350cef18e22832ba3cdad282ca5660d6fd49def92/coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6", size = 215364 }, - { url = "https://files.pythonhosted.org/packages/a6/93/4671ca5b2f3650c961a01252cbad96cb41f7c0c2b85c6062f27740a66b06/coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee", size = 246369 }, - { url = "https://files.pythonhosted.org/packages/64/79/2ca676c712d0540df0d7957a4266232980b60858a7a654846af1878cfde0/coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4", size = 248798 }, - { url = "https://files.pythonhosted.org/packages/82/c5/67e000b03ba5291f915ddd6ba7c3333e4fdee9ba003b914c8f8f2d966dfe/coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e", size = 250260 }, - { url = "https://files.pythonhosted.org/packages/9d/76/196783c425b5633db5c789b02a023858377bd73e4db4c805c2503cc42bbf/coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03", size = 248171 }, - { url = "https://files.pythonhosted.org/packages/83/1f/bf86c75f42de3641b4bbeab9712ec2815a3a8f5939768077245a492fad9f/coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535", size = 246368 }, - { url = "https://files.pythonhosted.org/packages/2d/95/bfc9a3abef0b160404438e82ec778a0f38660c66a4b0ed94d0417d4d2290/coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967", size = 247578 }, - { url = "https://files.pythonhosted.org/packages/c6/7e/4fb2a284d56fe2a3ba0c76806923014854a64e503dc8ce21e5a2e6497eea/coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84", size = 217521 }, - { url = "https://files.pythonhosted.org/packages/f7/30/3ab51058b75e9931fc48594d79888396cf009910fabebe12a6a636ab7f9e/coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d", size = 218308 }, - { url = "https://files.pythonhosted.org/packages/b0/34/2adc74fd132eaa1873b1688acb906b477216074ed8a37e90426eca6d2900/coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699", size = 216706 }, - { url = "https://files.pythonhosted.org/packages/fc/a7/a47f64718c2229b7860a334edd4e6ff41ec8513f3d3f4246284610344392/coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d", size = 215143 }, - { url = "https://files.pythonhosted.org/packages/ea/86/14d76a409e9ffab10d5aece73ac159dbd102fc56627e203413bfc6d53b24/coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586", size = 215401 }, - { url = "https://files.pythonhosted.org/packages/f4/b3/fb5c28148a19035a3877fac4e40b044a4c97b24658c980bcf7dff18bfab8/coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4", size = 245949 }, - { url = "https://files.pythonhosted.org/packages/6d/95/357559ecfe73970d2023845797361e6c2e6c2c05f970073fff186fe19dd7/coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6", size = 248295 }, - { url = "https://files.pythonhosted.org/packages/7e/58/bac5bc43085712af201f76a24733895331c475e5ddda88ac36c1332a65e6/coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32", size = 249733 }, - { url = "https://files.pythonhosted.org/packages/b2/db/104b713b3b74752ee365346677fb104765923982ae7bd93b95ca41fe256b/coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd", size = 247943 }, - { url = "https://files.pythonhosted.org/packages/32/4f/bef25c797c9496cf31ae9cfa93ce96b4414cacf13688e4a6000982772fd5/coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c", size = 245914 }, - { url = "https://files.pythonhosted.org/packages/36/6b/b3efa0b506dbb9a37830d6dc862438fe3ad2833c5f889152bce24d9577cf/coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88", size = 247296 }, - { url = "https://files.pythonhosted.org/packages/1f/aa/95a845266aeacab4c57b08e0f4e0e2899b07809a18fd0c1ddef2ac2c9138/coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0", size = 217566 }, - { url = "https://files.pythonhosted.org/packages/a0/d1/27b6e5073a8026b9e0f4224f1ac53217ce589a4cdab1bee878f23bff64f0/coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82", size = 218337 }, - { url = "https://files.pythonhosted.org/packages/c7/06/0e3ba498b11e2245fd96bd7e8dcdf90e1dd36d57f49f308aa650ff0561b8/coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957", size = 216740 }, - { url = "https://files.pythonhosted.org/packages/44/8b/11529debbe3e6b39ef6e7c8912554724adc6dc10adbb617a855ecfd387eb/coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3", size = 215866 }, - { url = "https://files.pythonhosted.org/packages/9c/6d/d8981310879e395f39af66536665b75135b1bc88dd21c7764e3340e9ce69/coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458", size = 216083 }, - { url = "https://files.pythonhosted.org/packages/c3/84/93295402de002de8b8c953bf6a1f19687174c4db7d44c1e85ffc153a772d/coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10", size = 257320 }, - { url = "https://files.pythonhosted.org/packages/02/5c/d0540db4869954dac0f69ad709adcd51f3a73ab11fcc9435ee76c518944a/coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157", size = 259182 }, - { url = "https://files.pythonhosted.org/packages/59/b2/d7d57a41a15ca4b47290862efd6b596d0a185bfd26f15d04db9f238aa56c/coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0", size = 261322 }, - { url = "https://files.pythonhosted.org/packages/16/92/fd828ae411b3da63673305617b6fbeccc09feb7dfe397d164f55a65cd880/coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18", size = 258914 }, - { url = "https://files.pythonhosted.org/packages/28/49/4aa5f5464b2e1215640c0400c5b007e7f5cdade8bf39c55c33b02f3a8c7f/coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b", size = 257051 }, - { url = "https://files.pythonhosted.org/packages/1e/5a/ded2346098c7f48ff6e135b5005b97de4cd9daec5c39adb4ecf3a60967da/coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460", size = 257869 }, - { url = "https://files.pythonhosted.org/packages/46/66/e06cedb8fc7d1c96630b2f549b8cdc084e2623dcc70c900cb3b705a36a60/coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda", size = 218243 }, - { url = "https://files.pythonhosted.org/packages/e7/1e/e84dd5ff35ed066bd6150e5c26fe0061ded2c59c209fd4f18db0650766c0/coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64", size = 219334 }, - { url = "https://files.pythonhosted.org/packages/b7/e0/b7b60b5dbc4e88eac0a0e9d5b4762409a59b29bf4e772b3509c8543ccaba/coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f", size = 217196 }, - { url = "https://files.pythonhosted.org/packages/15/c1/597b4fa7d6c0861d4916c4fe5c45bf30c11b31a3b07fedffed23dec5f765/coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574", size = 215139 }, - { url = "https://files.pythonhosted.org/packages/18/47/07973dcad0161355cf01ff0023ab34466b735deb460a178f37163d7c800e/coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78", size = 215419 }, - { url = "https://files.pythonhosted.org/packages/f6/f8/c65127782da312084ef909c1531226c869bfe22dac8b92d9c609d8150131/coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842", size = 245917 }, - { url = "https://files.pythonhosted.org/packages/05/97/a7f2fe79b6ae759ccc8740608cf9686ae406cc5e5591947ebbf1d679a325/coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0", size = 248225 }, - { url = "https://files.pythonhosted.org/packages/7f/d3/d2e1496d7ac3340356c5de582e08e14b02933e254924f79d18e9749269d8/coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244", size = 249844 }, - { url = "https://files.pythonhosted.org/packages/e5/7e/e26d966c9cae62500e5924107974ede2e985f7d119d10ed44d102998e509/coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0", size = 247871 }, - { url = "https://files.pythonhosted.org/packages/59/95/6a372a292dfb9d6e2cc019fc50878f7a6a5fbe704604018d7c5c1dbffb2d/coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e", size = 245714 }, - { url = "https://files.pythonhosted.org/packages/02/7f/63da22b7bc4e82e2c1df7755223291fc94fb01942cfe75e19f2bed96129e/coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5", size = 247131 }, - { url = "https://files.pythonhosted.org/packages/3d/af/883272555e34872879f48daea4207489cb36df249e3069e6a8a664dc6ba6/coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017", size = 217804 }, - { url = "https://files.pythonhosted.org/packages/90/f6/7afc3439994b7f7311d858438d49eef8b06eadbf2322502d921a110fae1e/coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653", size = 218596 }, - { url = "https://files.pythonhosted.org/packages/0b/99/7c715cfa155609ee3e71bc81b4d1265e1a9b79ad00cc3d19917ea736cbac/coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252", size = 216960 }, - { url = "https://files.pythonhosted.org/packages/59/18/5cb476346d3842f2e42cd92614a91921ebad38aa97aba63f2aab51919e35/coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50", size = 215881 }, - { url = "https://files.pythonhosted.org/packages/80/1b/c066d6836f4c1940a8df14894a5ec99db362838fdd9eee9fb7efe0e561d2/coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483", size = 216087 }, - { url = "https://files.pythonhosted.org/packages/1d/57/f0996fd468e70d4d24d69eba10ecc2b913c2e85d9f3c1bb2075ad7554c05/coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570", size = 257408 }, - { url = "https://files.pythonhosted.org/packages/36/78/c9f308b2b986cc685d4964a3b829b053817a07d7ba14ff124cf06154402e/coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f", size = 259373 }, - { url = "https://files.pythonhosted.org/packages/99/13/192827b71da71255d3554cb7dc289bce561cb281bda27e1b0dd19d88e47d/coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06", size = 261495 }, - { url = "https://files.pythonhosted.org/packages/0d/5c/cf4694353405abbb440a94468df8e5c4dbf884635da1f056b43be7284d28/coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5", size = 258970 }, - { url = "https://files.pythonhosted.org/packages/c7/83/fb45dac65c42eff6ce4153fe51b9f2a9fdc832ce57b7902ab9ff216c3faa/coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741", size = 257046 }, - { url = "https://files.pythonhosted.org/packages/60/95/577dc757c01f493a1951157475dd44561c82084387f12635974fb62e848c/coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a", size = 257946 }, - { url = "https://files.pythonhosted.org/packages/da/5a/14b1be12e3a71fcf4031464ae285dab7df0939976236d0462c4c5382d317/coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104", size = 218602 }, - { url = "https://files.pythonhosted.org/packages/a0/8d/c32890c0f4f7f71b8d4a1074ef8e9ef28e9b9c2f9fd0e2896f2cc32593bf/coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109", size = 219720 }, - { url = "https://files.pythonhosted.org/packages/22/f7/e5cc13338aa5e2780b6226fb50e9bd8f3f88da85a4b2951447b4b51109a4/coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426", size = 217374 }, - { url = "https://files.pythonhosted.org/packages/09/df/7c34bada8ace39f688b3bd5bc411459a20a3204ccb0984c90169a80a9366/coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f", size = 206777 }, + { url = "https://files.pythonhosted.org/packages/a5/3f/b051feeb292400bd22d071fdf933b3ad389a8cef5c80c7866ed0c7414b9e/coverage-7.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b7dc7f0a75a7eaa4584e5843c873c561b12602439d2351ee28c7478186c4da4", size = 214934 }, + { url = "https://files.pythonhosted.org/packages/f8/e4/a61b27d5c4c2d185bdfb0bfe9d15ab4ac4f0073032665544507429ae60eb/coverage-7.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:607f82389f0ecafc565813aa201a5cade04f897603750028dd660fb01797265e", size = 215173 }, + { url = "https://files.pythonhosted.org/packages/8a/01/40a6ee05b60d02d0bc53742ad4966e39dccd450aafb48c535a64390a3552/coverage-7.10.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f7da31a1ba31f1c1d4d5044b7c5813878adae1f3af8f4052d679cc493c7328f4", size = 246190 }, + { url = "https://files.pythonhosted.org/packages/11/ef/a28d64d702eb583c377255047281305dc5a5cfbfb0ee36e721f78255adb6/coverage-7.10.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51fe93f3fe4f5d8483d51072fddc65e717a175490804e1942c975a68e04bf97a", size = 248618 }, + { url = "https://files.pythonhosted.org/packages/6a/ad/73d018bb0c8317725370c79d69b5c6e0257df84a3b9b781bda27a438a3be/coverage-7.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e59d00830da411a1feef6ac828b90bbf74c9b6a8e87b8ca37964925bba76dbe", size = 250081 }, + { url = "https://files.pythonhosted.org/packages/2d/dd/496adfbbb4503ebca5d5b2de8bed5ec00c0a76558ffc5b834fd404166bc9/coverage-7.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:924563481c27941229cb4e16eefacc35da28563e80791b3ddc5597b062a5c386", size = 247990 }, + { url = "https://files.pythonhosted.org/packages/18/3c/a9331a7982facfac0d98a4a87b36ae666fe4257d0f00961a3a9ef73e015d/coverage-7.10.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ca79146ee421b259f8131f153102220b84d1a5e6fb9c8aed13b3badfd1796de6", size = 246191 }, + { url = "https://files.pythonhosted.org/packages/62/0c/75345895013b83f7afe92ec595e15a9a525ede17491677ceebb2ba5c3d85/coverage-7.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b225a06d227f23f386fdc0eab471506d9e644be699424814acc7d114595495f", size = 247400 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/98b268cfc5619ef9df1d5d34fee408ecb1542d9fd43d467e5c2f28668cd4/coverage-7.10.1-cp312-cp312-win32.whl", hash = "sha256:5ba9a8770effec5baaaab1567be916c87d8eea0c9ad11253722d86874d885eca", size = 217338 }, + { url = "https://files.pythonhosted.org/packages/fe/31/22a5440e4d1451f253c5cd69fdcead65e92ef08cd4ec237b8756dc0b20a7/coverage-7.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:9eb245a8d8dd0ad73b4062135a251ec55086fbc2c42e0eb9725a9b553fba18a3", size = 218125 }, + { url = "https://files.pythonhosted.org/packages/d6/2b/40d9f0ce7ee839f08a43c5bfc9d05cec28aaa7c9785837247f96cbe490b9/coverage-7.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:7718060dd4434cc719803a5e526838a5d66e4efa5dc46d2b25c21965a9c6fcc4", size = 216523 }, + { url = "https://files.pythonhosted.org/packages/ef/72/135ff5fef09b1ffe78dbe6fcf1e16b2e564cd35faeacf3d63d60d887f12d/coverage-7.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebb08d0867c5a25dffa4823377292a0ffd7aaafb218b5d4e2e106378b1061e39", size = 214960 }, + { url = "https://files.pythonhosted.org/packages/b1/aa/73a5d1a6fc08ca709a8177825616aa95ee6bf34d522517c2595484a3e6c9/coverage-7.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f32a95a83c2e17422f67af922a89422cd24c6fa94041f083dd0bb4f6057d0bc7", size = 215220 }, + { url = "https://files.pythonhosted.org/packages/8d/40/3124fdd45ed3772a42fc73ca41c091699b38a2c3bd4f9cb564162378e8b6/coverage-7.10.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c4c746d11c8aba4b9f58ca8bfc6fbfd0da4efe7960ae5540d1a1b13655ee8892", size = 245772 }, + { url = "https://files.pythonhosted.org/packages/42/62/a77b254822efa8c12ad59e8039f2bc3df56dc162ebda55e1943e35ba31a5/coverage-7.10.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7f39edd52c23e5c7ed94e0e4bf088928029edf86ef10b95413e5ea670c5e92d7", size = 248116 }, + { url = "https://files.pythonhosted.org/packages/1d/01/8101f062f472a3a6205b458d18ef0444a63ae5d36a8a5ed5dd0f6167f4db/coverage-7.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab6e19b684981d0cd968906e293d5628e89faacb27977c92f3600b201926b994", size = 249554 }, + { url = "https://files.pythonhosted.org/packages/8f/7b/e51bc61573e71ff7275a4f167aecbd16cb010aefdf54bcd8b0a133391263/coverage-7.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5121d8cf0eacb16133501455d216bb5f99899ae2f52d394fe45d59229e6611d0", size = 247766 }, + { url = "https://files.pythonhosted.org/packages/4b/71/1c96d66a51d4204a9d6d12df53c4071d87e110941a2a1fe94693192262f5/coverage-7.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df1c742ca6f46a6f6cbcaef9ac694dc2cb1260d30a6a2f5c68c5f5bcfee1cfd7", size = 245735 }, + { url = "https://files.pythonhosted.org/packages/13/d5/efbc2ac4d35ae2f22ef6df2ca084c60e13bd9378be68655e3268c80349ab/coverage-7.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40f9a38676f9c073bf4b9194707aa1eb97dca0e22cc3766d83879d72500132c7", size = 247118 }, + { url = "https://files.pythonhosted.org/packages/d1/22/073848352bec28ca65f2b6816b892fcf9a31abbef07b868487ad15dd55f1/coverage-7.10.1-cp313-cp313-win32.whl", hash = "sha256:2348631f049e884839553b9974f0821d39241c6ffb01a418efce434f7eba0fe7", size = 217381 }, + { url = "https://files.pythonhosted.org/packages/b7/df/df6a0ff33b042f000089bd11b6bb034bab073e2ab64a56e78ed882cba55d/coverage-7.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:4072b31361b0d6d23f750c524f694e1a417c1220a30d3ef02741eed28520c48e", size = 218152 }, + { url = "https://files.pythonhosted.org/packages/30/e3/5085ca849a40ed6b47cdb8f65471c2f754e19390b5a12fa8abd25cbfaa8f/coverage-7.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:3e31dfb8271937cab9425f19259b1b1d1f556790e98eb266009e7a61d337b6d4", size = 216559 }, + { url = "https://files.pythonhosted.org/packages/cc/93/58714efbfdeb547909feaabe1d67b2bdd59f0597060271b9c548d5efb529/coverage-7.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1c4f679c6b573a5257af6012f167a45be4c749c9925fd44d5178fd641ad8bf72", size = 215677 }, + { url = "https://files.pythonhosted.org/packages/c0/0c/18eaa5897e7e8cb3f8c45e563e23e8a85686b4585e29d53cacb6bc9cb340/coverage-7.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:871ebe8143da284bd77b84a9136200bd638be253618765d21a1fce71006d94af", size = 215899 }, + { url = "https://files.pythonhosted.org/packages/84/c1/9d1affacc3c75b5a184c140377701bbf14fc94619367f07a269cd9e4fed6/coverage-7.10.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:998c4751dabf7d29b30594af416e4bf5091f11f92a8d88eb1512c7ba136d1ed7", size = 257140 }, + { url = "https://files.pythonhosted.org/packages/3d/0f/339bc6b8fa968c346df346068cca1f24bdea2ddfa93bb3dc2e7749730962/coverage-7.10.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:780f750a25e7749d0af6b3631759c2c14f45de209f3faaa2398312d1c7a22759", size = 259005 }, + { url = "https://files.pythonhosted.org/packages/c8/22/89390864b92ea7c909079939b71baba7e5b42a76bf327c1d615bd829ba57/coverage-7.10.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:590bdba9445df4763bdbebc928d8182f094c1f3947a8dc0fc82ef014dbdd8324", size = 261143 }, + { url = "https://files.pythonhosted.org/packages/2c/56/3d04d89017c0c41c7a71bd69b29699d919b6bbf2649b8b2091240b97dd6a/coverage-7.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b2df80cb6a2af86d300e70acb82e9b79dab2c1e6971e44b78dbfc1a1e736b53", size = 258735 }, + { url = "https://files.pythonhosted.org/packages/cb/40/312252c8afa5ca781063a09d931f4b9409dc91526cd0b5a2b84143ffafa2/coverage-7.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d6a558c2725bfb6337bf57c1cd366c13798bfd3bfc9e3dd1f4a6f6fc95a4605f", size = 256871 }, + { url = "https://files.pythonhosted.org/packages/1f/2b/564947d5dede068215aaddb9e05638aeac079685101462218229ddea9113/coverage-7.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e6150d167f32f2a54690e572e0a4c90296fb000a18e9b26ab81a6489e24e78dd", size = 257692 }, + { url = "https://files.pythonhosted.org/packages/93/1b/c8a867ade85cb26d802aea2209b9c2c80613b9c122baa8c8ecea6799648f/coverage-7.10.1-cp313-cp313t-win32.whl", hash = "sha256:d946a0c067aa88be4a593aad1236493313bafaa27e2a2080bfe88db827972f3c", size = 218059 }, + { url = "https://files.pythonhosted.org/packages/a1/fe/cd4ab40570ae83a516bf5e754ea4388aeedd48e660e40c50b7713ed4f930/coverage-7.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e37c72eaccdd5ed1130c67a92ad38f5b2af66eeff7b0abe29534225db2ef7b18", size = 219150 }, + { url = "https://files.pythonhosted.org/packages/8d/16/6e5ed5854be6d70d0c39e9cb9dd2449f2c8c34455534c32c1a508c7dbdb5/coverage-7.10.1-cp313-cp313t-win_arm64.whl", hash = "sha256:89ec0ffc215c590c732918c95cd02b55c7d0f569d76b90bb1a5e78aa340618e4", size = 217014 }, + { url = "https://files.pythonhosted.org/packages/54/8e/6d0bfe9c3d7121cf936c5f8b03e8c3da1484fb801703127dba20fb8bd3c7/coverage-7.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:166d89c57e877e93d8827dac32cedae6b0277ca684c6511497311249f35a280c", size = 214951 }, + { url = "https://files.pythonhosted.org/packages/f2/29/e3e51a8c653cf2174c60532aafeb5065cea0911403fa144c9abe39790308/coverage-7.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bed4a2341b33cd1a7d9ffc47df4a78ee61d3416d43b4adc9e18b7d266650b83e", size = 215229 }, + { url = "https://files.pythonhosted.org/packages/e0/59/3c972080b2fa18b6c4510201f6d4dc87159d450627d062cd9ad051134062/coverage-7.10.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddca1e4f5f4c67980533df01430184c19b5359900e080248bbf4ed6789584d8b", size = 245738 }, + { url = "https://files.pythonhosted.org/packages/2e/04/fc0d99d3f809452654e958e1788454f6e27b34e43f8f8598191c8ad13537/coverage-7.10.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:37b69226001d8b7de7126cad7366b0778d36777e4d788c66991455ba817c5b41", size = 248045 }, + { url = "https://files.pythonhosted.org/packages/5e/2e/afcbf599e77e0dfbf4c97197747250d13d397d27e185b93987d9eaac053d/coverage-7.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2f22102197bcb1722691296f9e589f02b616f874e54a209284dd7b9294b0b7f", size = 249666 }, + { url = "https://files.pythonhosted.org/packages/6e/ae/bc47f7f8ecb7a06cbae2bf86a6fa20f479dd902bc80f57cff7730438059d/coverage-7.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e0c768b0f9ac5839dac5cf88992a4bb459e488ee8a1f8489af4cb33b1af00f1", size = 247692 }, + { url = "https://files.pythonhosted.org/packages/b6/26/cbfa3092d31ccba8ba7647e4d25753263e818b4547eba446b113d7d1efdf/coverage-7.10.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:991196702d5e0b120a8fef2664e1b9c333a81d36d5f6bcf6b225c0cf8b0451a2", size = 245536 }, + { url = "https://files.pythonhosted.org/packages/56/77/9c68e92500e6a1c83d024a70eadcc9a173f21aadd73c4675fe64c9c43fdf/coverage-7.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae8e59e5f4fd85d6ad34c2bb9d74037b5b11be072b8b7e9986beb11f957573d4", size = 246954 }, + { url = "https://files.pythonhosted.org/packages/7f/a5/ba96671c5a669672aacd9877a5987c8551501b602827b4e84256da2a30a7/coverage-7.10.1-cp314-cp314-win32.whl", hash = "sha256:042125c89cf74a074984002e165d61fe0e31c7bd40ebb4bbebf07939b5924613", size = 217616 }, + { url = "https://files.pythonhosted.org/packages/e7/3c/e1e1eb95fc1585f15a410208c4795db24a948e04d9bde818fe4eb893bc85/coverage-7.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22c3bfe09f7a530e2c94c87ff7af867259c91bef87ed2089cd69b783af7b84e", size = 218412 }, + { url = "https://files.pythonhosted.org/packages/b0/85/7e1e5be2cb966cba95566ba702b13a572ca744fbb3779df9888213762d67/coverage-7.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:ee6be07af68d9c4fca4027c70cea0c31a0f1bc9cb464ff3c84a1f916bf82e652", size = 216776 }, + { url = "https://files.pythonhosted.org/packages/62/0f/5bb8f29923141cca8560fe2217679caf4e0db643872c1945ac7d8748c2a7/coverage-7.10.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d24fb3c0c8ff0d517c5ca5de7cf3994a4cd559cde0315201511dbfa7ab528894", size = 215698 }, + { url = "https://files.pythonhosted.org/packages/80/29/547038ffa4e8e4d9e82f7dfc6d152f75fcdc0af146913f0ba03875211f03/coverage-7.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1217a54cfd79be20512a67ca81c7da3f2163f51bbfd188aab91054df012154f5", size = 215902 }, + { url = "https://files.pythonhosted.org/packages/e1/8a/7aaa8fbfaed900147987a424e112af2e7790e1ac9cd92601e5bd4e1ba60a/coverage-7.10.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:51f30da7a52c009667e02f125737229d7d8044ad84b79db454308033a7808ab2", size = 257230 }, + { url = "https://files.pythonhosted.org/packages/e5/1d/c252b5ffac44294e23a0d79dd5acf51749b39795ccc898faeabf7bee903f/coverage-7.10.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ed3718c757c82d920f1c94089066225ca2ad7f00bb904cb72b1c39ebdd906ccb", size = 259194 }, + { url = "https://files.pythonhosted.org/packages/16/ad/6c8d9f83d08f3bac2e7507534d0c48d1a4f52c18e6f94919d364edbdfa8f/coverage-7.10.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc452481e124a819ced0c25412ea2e144269ef2f2534b862d9f6a9dae4bda17b", size = 261316 }, + { url = "https://files.pythonhosted.org/packages/d6/4e/f9bbf3a36c061e2e0e0f78369c006d66416561a33d2bee63345aee8ee65e/coverage-7.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d6f494c307e5cb9b1e052ec1a471060f1dea092c8116e642e7a23e79d9388ea", size = 258794 }, + { url = "https://files.pythonhosted.org/packages/87/82/e600bbe78eb2cb0541751d03cef9314bcd0897e8eea156219c39b685f869/coverage-7.10.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fc0e46d86905ddd16b85991f1f4919028092b4e511689bbdaff0876bd8aab3dd", size = 256869 }, + { url = "https://files.pythonhosted.org/packages/ce/5d/2fc9a9236c5268f68ac011d97cd3a5ad16cc420535369bedbda659fdd9b7/coverage-7.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80b9ccd82e30038b61fc9a692a8dc4801504689651b281ed9109f10cc9fe8b4d", size = 257765 }, + { url = "https://files.pythonhosted.org/packages/8a/05/b4e00b2bd48a2dc8e1c7d2aea7455f40af2e36484ab2ef06deb85883e9fe/coverage-7.10.1-cp314-cp314t-win32.whl", hash = "sha256:e58991a2b213417285ec866d3cd32db17a6a88061a985dbb7e8e8f13af429c47", size = 218420 }, + { url = "https://files.pythonhosted.org/packages/77/fb/d21d05f33ea27ece327422240e69654b5932b0b29e7fbc40fbab3cf199bf/coverage-7.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e88dd71e4ecbc49d9d57d064117462c43f40a21a1383507811cf834a4a620651", size = 219536 }, + { url = "https://files.pythonhosted.org/packages/a6/68/7fea94b141281ed8be3d1d5c4319a97f2befc3e487ce33657fc64db2c45e/coverage-7.10.1-cp314-cp314t-win_arm64.whl", hash = "sha256:1aadfb06a30c62c2eb82322171fe1f7c288c80ca4156d46af0ca039052814bab", size = 217190 }, + { url = "https://files.pythonhosted.org/packages/0f/64/922899cff2c0fd3496be83fa8b81230f5a8d82a2ad30f98370b133c2c83b/coverage-7.10.1-py3-none-any.whl", hash = "sha256:fa2a258aa6bf188eb9a8948f7102a83da7c430a0dce918dbd8b60ef8fcb772d7", size = 206597 }, ] [[package]] @@ -772,6 +774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 }, ] +[[package]] +name = "python-vlc" +version = "3.0.21203" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -873,27 +884,26 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.5" +version = "0.12.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133 }, - { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114 }, - { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873 }, - { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829 }, - { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619 }, - { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894 }, - { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909 }, - { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652 }, - { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451 }, - { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465 }, - { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136 }, - { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644 }, - { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068 }, - { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537 }, - { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575 }, - { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273 }, - { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564 }, + { url = "https://files.pythonhosted.org/packages/0d/a2/364031a095e0d50277813b61c98918b8e5057a232f3b97bd39c3050898ad/ruff-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:59b48d8581989e0527b64c3297e672357c03b78d58cf1b228037a49915316277", size = 11855193 }, + { url = "https://files.pythonhosted.org/packages/84/4b/17060a0c01ff20329cb86aff0ec8ade03a033fb340a0e8276973395ba5d1/ruff-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:412518260394e8a6647a0c610062cac48ff230d39b9df57faae93aa77123e90c", size = 12522289 }, + { url = "https://files.pythonhosted.org/packages/e7/5b/ca87980044b163278eca24dc081a38101d3b2b5da3b57af28ca33f997f1e/ruff-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b56a3f51a27d0db8141d5b4b095c2849b24f639539a05d201f72f8d83f829a78", size = 11739924 }, + { url = "https://files.pythonhosted.org/packages/57/d9/2004a5c099d96f75931b318138c5bb39df6af7d9035b02c188e5024d3a35/ruff-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ef9e292957bd6a868ce4e5f57931d0583814a363add2adedae3a1c9854b7ad9", size = 11952620 }, + { url = "https://files.pythonhosted.org/packages/c5/2a/5bcc44d63823331e93b585797576b7e5bc581cd7eaf73f782bb2031dba81/ruff-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c3fd9955d3009c33e60bb596ea7bc66832de34d621883061114bb3b6114d358", size = 11662270 }, + { url = "https://files.pythonhosted.org/packages/56/5c/c2c56b605666353c139235a598a2ea073d51e65f9b615f6eee71b19657d3/ruff-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e7456efef8dd6957843de60a245152e34a842210d8b13381d5f3e7540d17935", size = 13232207 }, + { url = "https://files.pythonhosted.org/packages/ef/1d/301a4788986b9f31a12439503f643413f6188a6bd154ee11bd47ac5fd6c1/ruff-0.12.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c99e62bae20c7e1a8d4de84f96754e9732d0831614ed165415ed2c4f4aa83864", size = 14179966 }, + { url = "https://files.pythonhosted.org/packages/36/b1/5723f4d8f227351005c6c7a1cda1680a5357536be99f4a74da3fa51ebd76/ruff-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d47ff2b300da87df8437e1b35291349faaceb666d8349edef733b6562d29264f", size = 13629620 }, + { url = "https://files.pythonhosted.org/packages/62/a7/2f614b90698084b5d9985e741ae11d1581e90fdd7ffc37cb4730a0472725/ruff-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8883ab5e9506574a6a2abacb5da34d416fdd8434151b35421ba3f79ca9a14a11", size = 12667635 }, + { url = "https://files.pythonhosted.org/packages/f9/b3/2f71b72f47ea6d2352bafcc08ca02d5d80ace032dd5f0c43d30a49f2d02a/ruff-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3cfbd192c312669fb22cd4bf8c700e8b4b1dced7ce034e581459c0e375486fa", size = 12941871 }, + { url = "https://files.pythonhosted.org/packages/4f/fd/dd266e754d584a4f60652795bbc1ce0cffed83b9e897f6d479e5c73fca07/ruff-0.12.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c1d87f2b1abf330281b3972d6bf34d366ee84b3077df66a89169e2d81b291891", size = 11773663 }, + { url = "https://files.pythonhosted.org/packages/e6/15/9532fa52ac7a9c9c088ae77a60a626a4fb2a2d1e1e1fcca5ea082f1a9615/ruff-0.12.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3f32aaa9b5ed69de80693abeecf9961cd97851cadf7850081461261d0e6551b6", size = 11610539 }, + { url = "https://files.pythonhosted.org/packages/5e/a2/83dfcdec877bfba16589ed8c0463cb40c28e01cb52381af495146cf7b83b/ruff-0.12.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:de5185f19289a800c16d6ec8a9ba0b8b911b4640a4927b487f48fb51634ce315", size = 12485468 }, + { url = "https://files.pythonhosted.org/packages/9f/a7/e47be7e51e54945fdedcc10b43f819c3dffbd12a0378d7854fa43da7f9e8/ruff-0.12.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80f9d56205f6f6c4a1039c79d9acc0a9c104915f4fc0fc0385170decc72f6e4c", size = 12998871 }, + { url = "https://files.pythonhosted.org/packages/4a/6d/1b121d75ad74cb4e16b9f6e1e2493b178e64a84a8b57a3189fcf3dcce329/ruff-0.12.6-py3-none-win32.whl", hash = "sha256:b553271d6ed5611fcbe5f6752852eef695f2a77c0405b3a16fd507e5a057f5b0", size = 11747804 }, + { url = "https://files.pythonhosted.org/packages/2b/55/935b38ca28fd550a81b758743f66dfb060428b0c5e1995833865644f4d9d/ruff-0.12.6-py3-none-win_amd64.whl", hash = "sha256:48b73d4acef6768bfe9912e8f623ec87677bcfb6dc748ac406ebff06a84a6d70", size = 12906253 }, + { url = "https://files.pythonhosted.org/packages/55/68/0454d21dbc251e45da45c0cf0fd6db1253ec80d5888db0c1e11b25f21d5a/ruff-0.12.6-py3-none-win_arm64.whl", hash = "sha256:cd2c9c898a11f1441778d1cf9e358244cf5f4f2f11e93ff03c1a6c6759f4b15d", size = 11978598 }, ] [[package]]