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.
This commit is contained in:
JSC
2025-07-30 01:22:24 +02:00
parent 5ed19c8f0f
commit 1b0d291ad3
11 changed files with 2291 additions and 98 deletions

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter 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 # V1 API router with v1 prefix
api_router = APIRouter(prefix="/v1") api_router = APIRouter(prefix="/v1")
@@ -10,6 +10,7 @@ api_router = APIRouter(prefix="/v1")
# Include all route modules # Include all route modules
api_router.include_router(auth.router, tags=["authentication"]) api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(main.router, tags=["main"]) 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(playlists.router, tags=["playlists"])
api_router.include_router(socket.router, tags=["socket"]) api_router.include_router(socket.router, tags=["socket"])
api_router.include_router(sounds.router, tags=["sounds"]) api_router.include_router(sounds.router, tags=["sounds"])

228
app/api/v1/player.py Normal file
View File

@@ -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

View File

@@ -38,6 +38,13 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
await session.close() 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: async def init_db() -> None:
"""Initialize the database and create tables if they do not exist.""" """Initialize the database and create tables if they do not exist."""
logger = get_logger(__name__) logger = get_logger(__name__)

View File

@@ -6,10 +6,11 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import api_router 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.core.logging import get_logger, setup_logging
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
from app.services.extraction_processor import extraction_processor 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 from app.services.socket import socket_manager
@@ -27,10 +28,18 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await extraction_processor.start() await extraction_processor.start()
logger.info("Extraction processor started") logger.info("Extraction processor started")
# Start the player service
await initialize_player_service(get_session_factory())
logger.info("Player service started")
yield yield
logger.info("Shutting down application") logger.info("Shutting down application")
# Stop the player service
await shutdown_player_service()
logger.info("Player service stopped")
# Stop the extraction processor # Stop the extraction processor
await extraction_processor.stop() await extraction_processor.stop()
logger.info("Extraction processor stopped") logger.info("Extraction processor stopped")

View File

@@ -158,7 +158,10 @@ class ExtractionService:
service_info["service"], service_info["service_id"] service_info["service"], service_info["service_id"]
) )
if existing and existing.id != extraction_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) logger.warning(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
@@ -204,10 +207,10 @@ class ExtractionService:
sound_id = sound.id sound_id = sound.id
# Normalize the sound # Normalize the sound
await self._normalize_sound(sound) await self._normalize_sound(sound_id)
# Add to main playlist # 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 # Update extraction with success
await self.extraction_repo.update( await self.extraction_repo.update(
@@ -427,39 +430,45 @@ class ExtractionService:
return sound return sound
async def _normalize_sound(self, sound: Sound) -> None: async def _normalize_sound(self, sound_id: int) -> None:
"""Normalize the extracted sound.""" """Normalize the extracted sound."""
try: 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) normalizer_service = SoundNormalizerService(self.session)
result = await normalizer_service.normalize_sound(sound) result = await normalizer_service.normalize_sound(sound)
if result["status"] == "error": if result["status"] == "error":
logger.warning( logger.warning(
"Failed to normalize sound %d: %s", "Failed to normalize sound %d: %s",
sound.id, sound_id,
result.get("error"), result.get("error"),
) )
else: else:
logger.info("Successfully normalized sound %d", sound.id) logger.info("Successfully normalized sound %d", sound_id)
except Exception as e: 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 # 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.""" """Add the sound to the user's main playlist."""
try: 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( logger.info(
"Added sound %d to main playlist for user %d", "Added sound %d to main playlist for user %d",
sound.id, sound_id,
user_id, user_id,
) )
except Exception: except Exception:
logger.exception( logger.exception(
"Error adding sound %d to main playlist for user %d", "Error adding sound %d to main playlist for user %d",
sound.id, sound_id,
user_id, user_id,
) )
# Don't fail the extraction if playlist addition fails # Don't fail the extraction if playlist addition fails

637
app/services/player.py Normal file
View File

@@ -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

View File

@@ -13,7 +13,8 @@ dependencies = [
"httpx==0.28.1", "httpx==0.28.1",
"pydantic-settings==2.10.1", "pydantic-settings==2.10.1",
"pyjwt==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", "sqlmodel==0.0.24",
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.35.0",
"yt-dlp==2025.7.21", "yt-dlp==2025.7.21",
@@ -21,13 +22,13 @@ dependencies = [
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"coverage==7.10.0", "coverage==7.10.1",
"faker==37.4.2", "faker==37.4.2",
"httpx==0.28.1", "httpx==0.28.1",
"mypy==1.17.0", "mypy==1.17.0",
"pytest==8.4.1", "pytest==8.4.1",
"pytest-asyncio==1.1.0", "pytest-asyncio==1.1.0",
"ruff==0.12.5", "ruff==0.12.6",
] ]
[tool.mypy] [tool.mypy]

View File

@@ -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)

View File

@@ -329,6 +329,10 @@ class TestExtractionService:
hash="test_hash", hash="test_hash",
is_normalized=False, 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 = Mock()
mock_normalizer.normalize_sound = AsyncMock( mock_normalizer.normalize_sound = AsyncMock(
@@ -340,7 +344,8 @@ class TestExtractionService:
return_value=mock_normalizer, return_value=mock_normalizer,
): ):
# Should not raise exception # 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) mock_normalizer.normalize_sound.assert_called_once_with(sound)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -356,6 +361,10 @@ class TestExtractionService:
hash="test_hash", hash="test_hash",
is_normalized=False, 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 = Mock()
mock_normalizer.normalize_sound = AsyncMock( mock_normalizer.normalize_sound = AsyncMock(
@@ -367,7 +376,8 @@ class TestExtractionService:
return_value=mock_normalizer, return_value=mock_normalizer,
): ):
# Should not raise exception even on failure # 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) mock_normalizer.normalize_sound.assert_called_once_with(sound)
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -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()

170
uv.lock generated
View File

@@ -51,6 +51,7 @@ dependencies = [
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-socketio" }, { name = "python-socketio" },
{ name = "python-vlc" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "yt-dlp" }, { name = "yt-dlp" },
@@ -77,7 +78,8 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "pydantic-settings", specifier = "==2.10.1" }, { name = "pydantic-settings", specifier = "==2.10.1" },
{ name = "pyjwt", 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 = "sqlmodel", specifier = "==0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
{ name = "yt-dlp", specifier = "==2025.7.21" }, { name = "yt-dlp", specifier = "==2025.7.21" },
@@ -85,13 +87,13 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "coverage", specifier = "==7.10.0" }, { name = "coverage", specifier = "==7.10.1" },
{ name = "faker", specifier = "==37.4.2" }, { name = "faker", specifier = "==37.4.2" },
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "mypy", specifier = "==1.17.0" }, { name = "mypy", specifier = "==1.17.0" },
{ name = "pytest", specifier = "==8.4.1" }, { name = "pytest", specifier = "==8.4.1" },
{ name = "pytest-asyncio", specifier = "==1.1.0" }, { name = "pytest-asyncio", specifier = "==1.1.0" },
{ name = "ruff", specifier = "==0.12.5" }, { name = "ruff", specifier = "==0.12.6" },
] ]
[[package]] [[package]]
@@ -185,66 +187,66 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.10.0" version = "7.10.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/c6/7e/4fb2a284d56fe2a3ba0c76806923014854a64e503dc8ce21e5a2e6497eea/coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84", size = 217521 }, { 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/f7/30/3ab51058b75e9931fc48594d79888396cf009910fabebe12a6a636ab7f9e/coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d", size = 218308 }, { 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/b0/34/2adc74fd132eaa1873b1688acb906b477216074ed8a37e90426eca6d2900/coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699", size = 216706 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/1f/aa/95a845266aeacab4c57b08e0f4e0e2899b07809a18fd0c1ddef2ac2c9138/coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0", size = 217566 }, { 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/a0/d1/27b6e5073a8026b9e0f4224f1ac53217ce589a4cdab1bee878f23bff64f0/coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82", size = 218337 }, { 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/c7/06/0e3ba498b11e2245fd96bd7e8dcdf90e1dd36d57f49f308aa650ff0561b8/coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957", size = 216740 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/46/66/e06cedb8fc7d1c96630b2f549b8cdc084e2623dcc70c900cb3b705a36a60/coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda", size = 218243 }, { 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/e7/1e/e84dd5ff35ed066bd6150e5c26fe0061ded2c59c209fd4f18db0650766c0/coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64", size = 219334 }, { 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/b7/e0/b7b60b5dbc4e88eac0a0e9d5b4762409a59b29bf4e772b3509c8543ccaba/coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f", size = 217196 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/3d/af/883272555e34872879f48daea4207489cb36df249e3069e6a8a664dc6ba6/coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017", size = 217804 }, { 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/90/f6/7afc3439994b7f7311d858438d49eef8b06eadbf2322502d921a110fae1e/coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653", size = 218596 }, { 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/0b/99/7c715cfa155609ee3e71bc81b4d1265e1a9b79ad00cc3d19917ea736cbac/coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252", size = 216960 }, { 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/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/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/1b/c066d6836f4c1940a8df14894a5ec99db362838fdd9eee9fb7efe0e561d2/coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483", size = 216087 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/da/5a/14b1be12e3a71fcf4031464ae285dab7df0939976236d0462c4c5382d317/coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104", size = 218602 }, { 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/a0/8d/c32890c0f4f7f71b8d4a1074ef8e9ef28e9b9c2f9fd0e2896f2cc32593bf/coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109", size = 219720 }, { 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/22/f7/e5cc13338aa5e2780b6226fb50e9bd8f3f88da85a4b2951447b4b51109a4/coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426", size = 217374 }, { 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/09/df/7c34bada8ace39f688b3bd5bc411459a20a3204ccb0984c90169a80a9366/coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f", size = 206777 }, { url = "https://files.pythonhosted.org/packages/0f/64/922899cff2c0fd3496be83fa8b81230f5a8d82a2ad30f98370b133c2c83b/coverage-7.10.1-py3-none-any.whl", hash = "sha256:fa2a258aa6bf188eb9a8948f7102a83da7c430a0dce918dbd8b60ef8fcb772d7", size = 206597 },
] ]
[[package]] [[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 }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.2" version = "6.0.2"
@@ -873,27 +884,26 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.5" version = "0.12.6"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/0d/a2/364031a095e0d50277813b61c98918b8e5057a232f3b97bd39c3050898ad/ruff-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:59b48d8581989e0527b64c3297e672357c03b78d58cf1b228037a49915316277", size = 11855193 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575 }, { 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/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273 }, { 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/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564 }, { url = "https://files.pythonhosted.org/packages/55/68/0454d21dbc251e45da45c0cf0fd6db1253ec80d5888db0c1e11b25f21d5a/ruff-0.12.6-py3-none-win_arm64.whl", hash = "sha256:cd2c9c898a11f1441778d1cf9e358244cf5f4f2f11e93ff03c1a6c6759f4b15d", size = 11978598 },
] ]
[[package]] [[package]]