fix: Lint fixes of services
All checks were successful
Backend CI / test (push) Successful in 3m59s

This commit is contained in:
JSC
2025-08-01 01:27:47 +02:00
parent 95ccb76233
commit a10111793c
12 changed files with 237 additions and 160 deletions

View File

@@ -400,7 +400,6 @@ async def play_sound_with_vlc(
await credit_service.validate_and_reserve_credits( await credit_service.validate_and_reserve_credits(
current_user.id, current_user.id,
CreditActionType.VLC_PLAY_SOUND, CreditActionType.VLC_PLAY_SOUND,
{"sound_id": sound_id, "sound_name": sound.name},
) )
except InsufficientCreditsError as e: except InsufficientCreditsError as e:
raise HTTPException( raise HTTPException(
@@ -418,8 +417,8 @@ async def play_sound_with_vlc(
await credit_service.deduct_credits( await credit_service.deduct_credits(
current_user.id, current_user.id,
CreditActionType.VLC_PLAY_SOUND, CreditActionType.VLC_PLAY_SOUND,
success, success=success,
{"sound_id": sound_id, "sound_name": sound.name}, metadata={"sound_id": sound_id, "sound_name": sound.name},
) )
if not success: if not success:

View File

@@ -76,14 +76,12 @@ class CreditService:
self, self,
user_id: int, user_id: int,
action_type: CreditActionType, action_type: CreditActionType,
metadata: dict[str, Any] | None = None,
) -> tuple[User, CreditAction]: ) -> tuple[User, CreditAction]:
"""Validate user has sufficient credits and optionally reserve them. """Validate user has sufficient credits and optionally reserve them.
Args: Args:
user_id: The user ID user_id: The user ID
action_type: The type of action action_type: The type of action
metadata: Optional metadata to store with transaction
Returns: Returns:
Tuple of (user, credit_action) Tuple of (user, credit_action)
@@ -118,6 +116,7 @@ class CreditService:
self, self,
user_id: int, user_id: int,
action_type: CreditActionType, action_type: CreditActionType,
*,
success: bool = True, success: bool = True,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> CreditTransaction: ) -> CreditTransaction:
@@ -139,18 +138,26 @@ class CreditService:
""" """
action = get_credit_action(action_type) action = get_credit_action(action_type)
# Only deduct if action requires success and was successful, or doesn't require success # Only deduct if action requires success and was successful,
should_deduct = (action.requires_success and success) or not action.requires_success # or doesn't require success
should_deduct = (
action.requires_success and success
) or not action.requires_success
if not should_deduct: if not should_deduct:
logger.info( logger.info(
"Skipping credit deduction for user %s: action %s failed and requires success", "Skipping credit deduction for user %s: "
"action %s failed and requires success",
user_id, user_id,
action_type.value, action_type.value,
) )
# Still create a transaction record for auditing # Still create a transaction record for auditing
return await self._create_transaction_record( return await self._create_transaction_record(
user_id, action, 0, success, metadata, user_id,
action,
0,
success=success,
metadata=metadata,
) )
session = self.db_session_factory() session = self.db_session_factory()
@@ -204,14 +211,19 @@ class CreditService:
"action_type": action_type.value, "action_type": action_type.value,
"success": success, "success": success,
} }
await socket_manager.send_to_user(str(user_id), "user_credits_changed", event_data) await socket_manager.send_to_user(
str(user_id),
"user_credits_changed",
event_data,
)
logger.info("Emitted user_credits_changed event for user %s", user_id) logger.info("Emitted user_credits_changed event for user %s", user_id)
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to emit user_credits_changed event for user %s", user_id, "Failed to emit user_credits_changed event for user %s",
user_id,
) )
else:
return transaction return transaction
except Exception: except Exception:
await session.rollback() await session.rollback()
@@ -292,14 +304,19 @@ class CreditService:
"description": description, "description": description,
"success": True, "success": True,
} }
await socket_manager.send_to_user(str(user_id), "user_credits_changed", event_data) await socket_manager.send_to_user(
str(user_id),
"user_credits_changed",
event_data,
)
logger.info("Emitted user_credits_changed event for user %s", user_id) logger.info("Emitted user_credits_changed event for user %s", user_id)
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to emit user_credits_changed event for user %s", user_id, "Failed to emit user_credits_changed event for user %s",
user_id,
) )
else:
return transaction return transaction
except Exception: except Exception:
await session.rollback() await session.rollback()
@@ -312,6 +329,7 @@ class CreditService:
user_id: int, user_id: int,
action: CreditAction, action: CreditAction,
amount: int, amount: int,
*,
success: bool, success: bool,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> CreditTransaction: ) -> CreditTransaction:
@@ -342,19 +360,22 @@ class CreditService:
amount=amount, amount=amount,
balance_before=user.credits, balance_before=user.credits,
balance_after=user.credits, balance_after=user.credits,
description=f"{action.description} (failed)" if not success else action.description, description=(
f"{action.description} (failed)"
if not success
else action.description
),
success=success, success=success,
metadata_json=json.dumps(metadata) if metadata else None, metadata_json=json.dumps(metadata) if metadata else None,
) )
session.add(transaction) session.add(transaction)
await session.commit() await session.commit()
return transaction
except Exception: except Exception:
await session.rollback() await session.rollback()
raise raise
else:
return transaction
finally: finally:
await session.close() await session.close()

View File

@@ -75,7 +75,10 @@ class ExtractionService:
extraction = await self.extraction_repo.create(extraction_data) extraction = await self.extraction_repo.create(extraction_data)
logger.info("Created extraction with ID: %d", extraction.id) logger.info("Created extraction with ID: %d", extraction.id)
except Exception:
logger.exception("Failed to create extraction for URL: %s", url)
raise
else:
return { return {
"id": extraction.id or 0, # Should never be None for created extraction "id": extraction.id or 0, # Should never be None for created extraction
"url": extraction.url, "url": extraction.url,
@@ -87,10 +90,6 @@ class ExtractionService:
"sound_id": extraction.sound_id, "sound_id": extraction.sound_id,
} }
except Exception:
logger.exception("Failed to create extraction for URL: %s", url)
raise
async def _detect_service_info(self, url: str) -> dict[str, str | None] | None: async def _detect_service_info(self, url: str) -> dict[str, str | None] | None:
"""Detect service information from URL using yt-dlp.""" """Detect service information from URL using yt-dlp."""
try: try:
@@ -126,10 +125,12 @@ class ExtractionService:
"""Process an extraction job.""" """Process an extraction job."""
extraction = await self.extraction_repo.get_by_id(extraction_id) extraction = await self.extraction_repo.get_by_id(extraction_id)
if not extraction: if not extraction:
raise ValueError(f"Extraction {extraction_id} not found") msg = f"Extraction {extraction_id} not found"
raise ValueError(msg)
if extraction.status != "pending": if extraction.status != "pending":
raise ValueError(f"Extraction {extraction_id} is not pending") msg = f"Extraction {extraction_id} is not pending"
raise ValueError(msg)
# Store all needed values early to avoid session detachment issues # Store all needed values early to avoid session detachment issues
user_id = extraction.user_id user_id = extraction.user_id
@@ -150,7 +151,8 @@ class ExtractionService:
service_info = await self._detect_service_info(extraction_url) service_info = await self._detect_service_info(extraction_url)
if not service_info: if not service_info:
raise ValueError("Unable to detect service information from URL") msg = "Unable to detect service information from URL"
raise ValueError(msg)
# Check if extraction already exists for this service # Check if extraction already exists for this service
existing = await self.extraction_repo.get_by_service_and_id( existing = await self.extraction_repo.get_by_service_and_id(
@@ -222,7 +224,12 @@ class ExtractionService:
) )
logger.info("Successfully processed extraction %d", extraction_id) logger.info("Successfully processed extraction %d", extraction_id)
except Exception as e:
error_msg = str(e)
logger.exception(
"Failed to process extraction %d: %s", extraction_id, error_msg,
)
else:
return { return {
"id": extraction_id, "id": extraction_id,
"url": extraction_url, "url": extraction_url,
@@ -234,12 +241,6 @@ class ExtractionService:
"sound_id": sound_id, "sound_id": sound_id,
} }
except Exception as e:
error_msg = str(e)
logger.exception(
"Failed to process extraction %d: %s", extraction_id, error_msg,
)
# Update extraction with error # Update extraction with error
await self.extraction_repo.update( await self.extraction_repo.update(
extraction, extraction,
@@ -313,7 +314,8 @@ class ExtractionService:
) )
if not audio_files: if not audio_files:
raise RuntimeError("No audio file was created during extraction") msg = "No audio file was created during extraction"
raise RuntimeError(msg)
audio_file = audio_files[0] audio_file = audio_files[0]
thumbnail_file = thumbnail_files[0] if thumbnail_files else None thumbnail_file = thumbnail_files[0] if thumbnail_files else None
@@ -324,11 +326,12 @@ class ExtractionService:
thumbnail_file or "None", thumbnail_file or "None",
) )
return audio_file, thumbnail_file
except Exception as e: except Exception as e:
logger.exception("yt-dlp extraction failed for %s", extraction_url) logger.exception("yt-dlp extraction failed for %s", extraction_url)
raise RuntimeError(f"Audio extraction failed: {e}") from e error_msg = f"Audio extraction failed: {e}"
raise RuntimeError(error_msg) from e
else:
return audio_file, thumbnail_file
async def _move_files_to_final_location( async def _move_files_to_final_location(
self, self,
@@ -450,8 +453,8 @@ class ExtractionService:
else: else:
logger.info("Successfully normalized sound %d", sound_id) logger.info("Successfully normalized sound %d", sound_id)
except Exception as e: except Exception:
logger.exception("Error normalizing sound %d: %s", sound_id, e) logger.exception("Error normalizing sound %d", sound_id)
# Don't fail the extraction if normalization fails # Don't fail the extraction if normalization fails
async def _add_to_main_playlist(self, sound_id: int, user_id: int) -> None: async def _add_to_main_playlist(self, sound_id: int, user_id: int) -> None:

View File

@@ -1,6 +1,7 @@
"""Background extraction processor for handling extraction queue.""" """Background extraction processor for handling extraction queue."""
import asyncio import asyncio
import contextlib
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -51,10 +52,8 @@ class ExtractionProcessor:
"Extraction processor did not stop gracefully, cancelling...", "Extraction processor did not stop gracefully, cancelling...",
) )
self.processor_task.cancel() self.processor_task.cancel()
try: with contextlib.suppress(asyncio.CancelledError):
await self.processor_task await self.processor_task
except asyncio.CancelledError:
pass
logger.info("Extraction processor stopped") logger.info("Extraction processor stopped")
@@ -84,8 +83,8 @@ class ExtractionProcessor:
except TimeoutError: except TimeoutError:
continue # Continue processing continue # Continue processing
except Exception as e: except Exception:
logger.exception("Error in extraction queue processor: %s", e) logger.exception("Error in extraction queue processor")
# Wait a bit before retrying to avoid tight error loops # Wait a bit before retrying to avoid tight error loops
try: try:
await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0) await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0)
@@ -156,8 +155,8 @@ class ExtractionProcessor:
result["status"], result["status"],
) )
except Exception as e: except Exception:
logger.exception("Error processing extraction %d: %s", extraction_id, e) logger.exception("Error processing extraction %d", extraction_id)
def _on_extraction_completed(self, extraction_id: int, task: asyncio.Task) -> None: def _on_extraction_completed(self, extraction_id: int, task: asyncio.Task) -> None:
"""Handle completion of an extraction task.""" """Handle completion of an extraction task."""

View File

@@ -68,15 +68,19 @@ class PlayerState:
"duration": self.current_sound_duration, "duration": self.current_sound_duration,
"index": self.current_sound_index, "index": self.current_sound_index,
"current_sound": self._serialize_sound(self.current_sound), "current_sound": self._serialize_sound(self.current_sound),
"playlist": { "playlist": (
"id": self.playlist_id, {
"name": self.playlist_name, "id": self.playlist_id,
"length": self.playlist_length, "name": self.playlist_name,
"duration": self.playlist_duration, "length": self.playlist_length,
"sounds": [ "duration": self.playlist_duration,
self._serialize_sound(sound) for sound in self.playlist_sounds "sounds": [
], self._serialize_sound(sound) for sound in self.playlist_sounds
} if self.playlist_id else None, ],
}
if self.playlist_id
else None
),
} }
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None: def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
@@ -103,6 +107,14 @@ class PlayerService:
self.db_session_factory = db_session_factory self.db_session_factory = db_session_factory
self.state = PlayerState() self.state = PlayerState()
self._vlc_instance = vlc.Instance() self._vlc_instance = vlc.Instance()
if self._vlc_instance is None:
msg = (
"VLC instance could not be created. "
"Ensure VLC is installed and accessible."
)
raise RuntimeError(msg)
self._player = self._vlc_instance.media_player_new() self._player = self._vlc_instance.media_player_new()
self._is_running = False self._is_running = False
self._position_thread: threading.Thread | None = None self._position_thread: threading.Thread | None = None
@@ -125,7 +137,8 @@ class PlayerService:
# Start position tracking thread # Start position tracking thread
self._position_thread = threading.Thread( self._position_thread = threading.Thread(
target=self._position_tracker, daemon=True, target=self._position_tracker,
daemon=True,
) )
self._position_thread.start() self._position_thread.start()
@@ -155,9 +168,9 @@ class PlayerService:
"""Play audio at specified index or current position.""" """Play audio at specified index or current position."""
# Check if we're resuming from pause # Check if we're resuming from pause
is_resuming = ( is_resuming = (
index is None and index is None
self.state.status == PlayerStatus.PAUSED and and self.state.status == PlayerStatus.PAUSED
self.state.current_sound is not None and self.state.current_sound is not None
) )
if is_resuming: if is_resuming:
@@ -179,7 +192,14 @@ class PlayerService:
} }
await self._broadcast_state() await self._broadcast_state()
logger.info("Resumed playing sound: %s", self.state.current_sound.name) logger.info(
"Resumed playing sound: %s",
(
self.state.current_sound.name
if self.state.current_sound
else "Unknown"
),
)
else: else:
logger.error("Failed to resume playback: VLC error code %s", result) logger.error("Failed to resume playback: VLC error code %s", result)
return return
@@ -204,6 +224,10 @@ class PlayerService:
return return
# Load and play media (new track) # Load and play media (new track)
if self._vlc_instance is None:
logger.error("VLC instance is not initialized. Cannot play media.")
return
media = self._vlc_instance.media_new(str(sound_path)) media = self._vlc_instance.media_new(str(sound_path))
self._player.set_media(media) self._player.set_media(media)
@@ -354,7 +378,9 @@ class PlayerService:
and previous_playlist_id != current_playlist.id and previous_playlist_id != current_playlist.id
): ):
await self._handle_playlist_id_changed( await self._handle_playlist_id_changed(
previous_playlist_id, current_playlist.id, sounds, previous_playlist_id,
current_playlist.id,
sounds,
) )
elif previous_current_sound_id: elif previous_current_sound_id:
await self._handle_same_playlist_track_check( await self._handle_same_playlist_track_check(
@@ -432,7 +458,9 @@ class PlayerService:
self._clear_current_track() self._clear_current_track()
def _update_playlist_state( def _update_playlist_state(
self, current_playlist: Playlist, sounds: list[Sound], self,
current_playlist: Playlist,
sounds: list[Sound],
) -> None: ) -> None:
"""Update basic playlist state information.""" """Update basic playlist state information."""
self.state.playlist_id = current_playlist.id self.state.playlist_id = current_playlist.id
@@ -464,7 +492,6 @@ class PlayerService:
"""Get current player state.""" """Get current player state."""
return self.state.to_dict() return self.state.to_dict()
def _get_next_index(self, current_index: int) -> int | None: def _get_next_index(self, current_index: int) -> int | None:
"""Get next track index based on current mode.""" """Get next track index based on current mode."""
if not self.state.playlist_sounds: if not self.state.playlist_sounds:
@@ -497,11 +524,7 @@ class PlayerService:
prev_index = current_index - 1 prev_index = current_index - 1
if prev_index < 0: if prev_index < 0:
return ( return playlist_length - 1 if self.state.mode == PlayerMode.LOOP else None
playlist_length - 1
if self.state.mode == PlayerMode.LOOP
else None
)
return prev_index return prev_index
def _position_tracker(self) -> None: def _position_tracker(self) -> None:
@@ -535,10 +558,7 @@ class PlayerService:
def _update_play_time(self) -> None: def _update_play_time(self) -> None:
"""Update play time tracking for current sound.""" """Update play time tracking for current sound."""
if ( if not self.state.current_sound_id or self.state.status != PlayerStatus.PLAYING:
not self.state.current_sound_id
or self.state.status != PlayerStatus.PLAYING
):
return return
sound_id = self.state.current_sound_id sound_id = self.state.current_sound_id
@@ -577,10 +597,8 @@ class PlayerService:
sound_id, sound_id,
tracking["total_time"], tracking["total_time"],
self.state.current_sound_duration, self.state.current_sound_duration,
( (tracking["total_time"] / self.state.current_sound_duration)
tracking["total_time"] * 100,
/ self.state.current_sound_duration
) * 100,
) )
self._schedule_async_task(self._record_play_count(sound_id)) self._schedule_async_task(self._record_play_count(sound_id))
@@ -596,7 +614,8 @@ class PlayerService:
if sound: if sound:
old_count = sound.play_count old_count = sound.play_count
await sound_repo.update( await sound_repo.update(
sound, {"play_count": sound.play_count + 1}, sound,
{"play_count": sound.play_count + 1},
) )
logger.info( logger.info(
"Updated sound %s play_count: %s -> %s", "Updated sound %s play_count: %s -> %s",

View File

@@ -69,6 +69,7 @@ class PlaylistService:
name: str, name: str,
description: str | None = None, description: str | None = None,
genre: str | None = None, genre: str | None = None,
*,
is_main: bool = False, is_main: bool = False,
is_current: bool = False, is_current: bool = False,
is_deletable: bool = True, is_deletable: bool = True,
@@ -104,6 +105,7 @@ class PlaylistService:
self, self,
playlist_id: int, playlist_id: int,
user_id: int, user_id: int,
*,
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
genre: str | None = None, genre: str | None = None,
@@ -179,7 +181,11 @@ class PlaylistService:
return await self.playlist_repo.get_playlist_sounds(playlist_id) return await self.playlist_repo.get_playlist_sounds(playlist_id)
async def add_sound_to_playlist( async def add_sound_to_playlist(
self, playlist_id: int, sound_id: int, user_id: int, position: int | None = None, self,
playlist_id: int,
sound_id: int,
user_id: int,
position: int | None = None,
) -> None: ) -> None:
"""Add a sound to a playlist.""" """Add a sound to a playlist."""
# Verify playlist exists # Verify playlist exists
@@ -202,11 +208,17 @@ class PlaylistService:
await self.playlist_repo.add_sound_to_playlist(playlist_id, sound_id, position) await self.playlist_repo.add_sound_to_playlist(playlist_id, sound_id, position)
logger.info( logger.info(
"Added sound %s to playlist %s for user %s", sound_id, playlist_id, user_id, "Added sound %s to playlist %s for user %s",
sound_id,
playlist_id,
user_id,
) )
async def remove_sound_from_playlist( async def remove_sound_from_playlist(
self, playlist_id: int, sound_id: int, user_id: int, self,
playlist_id: int,
sound_id: int,
user_id: int,
) -> None: ) -> None:
"""Remove a sound from a playlist.""" """Remove a sound from a playlist."""
# Verify playlist exists # Verify playlist exists
@@ -228,7 +240,10 @@ class PlaylistService:
) )
async def reorder_playlist_sounds( async def reorder_playlist_sounds(
self, playlist_id: int, user_id: int, sound_positions: list[tuple[int, int]], self,
playlist_id: int,
user_id: int,
sound_positions: list[tuple[int, int]],
) -> None: ) -> None:
"""Reorder sounds in a playlist.""" """Reorder sounds in a playlist."""
# Verify playlist exists # Verify playlist exists
@@ -262,7 +277,8 @@ class PlaylistService:
await self._unset_current_playlist(user_id) await self._unset_current_playlist(user_id)
await self._set_main_as_current(user_id) await self._set_main_as_current(user_id)
logger.info( logger.info(
"Unset current playlist and set main as current for user %s", user_id, "Unset current playlist and set main as current for user %s",
user_id,
) )
async def get_playlist_stats(self, playlist_id: int) -> dict[str, Any]: async def get_playlist_stats(self, playlist_id: int) -> dict[str, Any]:
@@ -286,11 +302,13 @@ class PlaylistService:
main_playlist = await self.get_main_playlist() main_playlist = await self.get_main_playlist()
if main_playlist.id is None: if main_playlist.id is None:
raise ValueError("Main playlist has no ID") msg = "Main playlist has no ID, cannot add sound"
raise ValueError(msg)
# Check if sound is already in main playlist # Check if sound is already in main playlist
if not await self.playlist_repo.is_sound_in_playlist( if not await self.playlist_repo.is_sound_in_playlist(
main_playlist.id, sound_id, main_playlist.id,
sound_id,
): ):
await self.playlist_repo.add_sound_to_playlist(main_playlist.id, sound_id) await self.playlist_repo.add_sound_to_playlist(main_playlist.id, sound_id)
logger.info( logger.info(

View File

@@ -13,7 +13,8 @@ logger = logging.getLogger(__name__)
class SocketManager: class SocketManager:
"""Manages WebSocket connections and user rooms.""" """Manages WebSocket connections and user rooms."""
def __init__(self): def __init__(self) -> None:
"""Initialize the SocketManager with a Socket.IO server."""
self.sio = socketio.AsyncServer( self.sio = socketio.AsyncServer(
cors_allowed_origins=["http://localhost:8001"], cors_allowed_origins=["http://localhost:8001"],
logger=True, logger=True,
@@ -27,20 +28,20 @@ class SocketManager:
self._setup_handlers() self._setup_handlers()
def _setup_handlers(self): def _setup_handlers(self) -> None:
"""Set up socket event handlers.""" """Set up socket event handlers."""
@self.sio.event @self.sio.event
async def connect(sid, environ, auth=None): async def connect(sid: str, environ: dict) -> None:
"""Handle client connection.""" """Handle client connection."""
logger.info(f"Client {sid} attempting to connect") logger.info("Client %s attempting to connect", sid)
# Extract access token from cookies # Extract access token from cookies
cookie_header = environ.get("HTTP_COOKIE", "") cookie_header = environ.get("HTTP_COOKIE", "")
access_token = extract_access_token_from_cookies(cookie_header) access_token = extract_access_token_from_cookies(cookie_header)
if not access_token: if not access_token:
logger.warning(f"Client {sid} connecting without access token") logger.warning("Client %s connecting without access token", sid)
await self.sio.disconnect(sid) await self.sio.disconnect(sid)
return return
@@ -50,13 +51,13 @@ class SocketManager:
user_id = payload.get("sub") user_id = payload.get("sub")
if not user_id: if not user_id:
logger.warning(f"Client {sid} token missing user ID") logger.warning("Client %s token missing user ID", sid)
await self.sio.disconnect(sid) await self.sio.disconnect(sid)
return return
logger.info(f"User {user_id} connected with socket {sid}") logger.info("User %s connected with socket %s", user_id, sid)
except Exception as e: except Exception:
logger.warning(f"Client {sid} invalid token: {e}") logger.exception("Client %s invalid token", sid)
await self.sio.disconnect(sid) await self.sio.disconnect(sid)
return return
@@ -70,7 +71,7 @@ class SocketManager:
# Update room tracking # Update room tracking
self.user_rooms[user_id] = room_id self.user_rooms[user_id] = room_id
logger.info(f"User {user_id} joined room {room_id}") logger.info("User %s joined room %s", user_id, room_id)
# Send welcome message to user # Send welcome message to user
await self.sio.emit( await self.sio.emit(
@@ -84,33 +85,33 @@ class SocketManager:
) )
@self.sio.event @self.sio.event
async def disconnect(sid): async def disconnect(sid: str) -> None:
"""Handle client disconnection.""" """Handle client disconnection."""
user_id = self.socket_users.get(sid) user_id = self.socket_users.get(sid)
if user_id: if user_id:
logger.info(f"User {user_id} disconnected (socket {sid})") logger.info("User %s disconnected (socket %s)", user_id, sid)
# Clean up mappings # Clean up mappings
del self.socket_users[sid] del self.socket_users[sid]
if user_id in self.user_rooms: if user_id in self.user_rooms:
del self.user_rooms[user_id] del self.user_rooms[user_id]
else: else:
logger.info(f"Unknown client {sid} disconnected") logger.info("Unknown client %s disconnected", sid)
async def send_to_user(self, user_id: str, event: str, data: dict): async def send_to_user(self, user_id: str, event: str, data: dict) -> bool:
"""Send a message to a specific user's room.""" """Send a message to a specific user's room."""
room_id = self.user_rooms.get(user_id) room_id = self.user_rooms.get(user_id)
if room_id: if room_id:
await self.sio.emit(event, data, room=room_id) await self.sio.emit(event, data, room=room_id)
logger.debug(f"Sent {event} to user {user_id} in room {room_id}") logger.debug("Sent %s to user %s in room %s", event, user_id, room_id)
return True return True
logger.warning(f"User {user_id} not found in any room") logger.warning("User %s not found in any room", user_id)
return False return False
async def broadcast_to_all(self, event: str, data: dict): async def broadcast_to_all(self, event: str, data: dict) -> None:
"""Broadcast a message to all connected users.""" """Broadcast a message to all connected users."""
await self.sio.emit(event, data) await self.sio.emit(event, data)
logger.info(f"Broadcasted {event} to all users") logger.info("Broadcasted %s to all users", event)
def get_connected_users(self) -> list: def get_connected_users(self) -> list:
"""Get list of currently connected user IDs.""" """Get list of currently connected user IDs."""

View File

@@ -153,7 +153,9 @@ class SoundNormalizerService:
"""Normalize audio using two-pass loudnorm for better quality.""" """Normalize audio using two-pass loudnorm for better quality."""
try: try:
logger.info( logger.info(
"Starting two-pass normalization: %s -> %s", input_path, output_path, "Starting two-pass normalization: %s -> %s",
input_path,
output_path,
) )
# First pass: analyze # First pass: analyze
@@ -177,7 +179,7 @@ class SoundNormalizerService:
result = ffmpeg.run(stream, capture_stderr=True, quiet=True) result = ffmpeg.run(stream, capture_stderr=True, quiet=True)
analysis_output = result[1].decode("utf-8") analysis_output = result[1].decode("utf-8")
except ffmpeg.Error as e: except ffmpeg.Error as e:
logger.error( logger.exception(
"FFmpeg first pass failed for %s. Stdout: %s, Stderr: %s", "FFmpeg first pass failed for %s. Stdout: %s, Stderr: %s",
input_path, input_path,
e.stdout.decode() if e.stdout else "None", e.stdout.decode() if e.stdout else "None",
@@ -193,9 +195,11 @@ class SoundNormalizerService:
json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', analysis_output) json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', analysis_output)
if not json_match: if not json_match:
logger.error( logger.error(
"Could not find JSON in loudnorm output: %s", analysis_output, "Could not find JSON in loudnorm output: %s",
analysis_output,
) )
raise ValueError("Could not extract loudnorm analysis data") msg = "Could not find JSON in loudnorm output"
raise ValueError(msg)
logger.debug("Found JSON match: %s", json_match.group()) logger.debug("Found JSON match: %s", json_match.group())
analysis_data = json.loads(json_match.group()) analysis_data = json.loads(json_match.group())
@@ -211,7 +215,10 @@ class SoundNormalizerService:
]: ]:
if str(analysis_data.get(key, "")).lower() in invalid_values: if str(analysis_data.get(key, "")).lower() in invalid_values:
logger.warning( logger.warning(
"Invalid analysis value for %s: %s. Falling back to one-pass normalization.", (
"Invalid analysis value for %s: %s. "
"Falling back to one-pass normalization."
),
key, key,
analysis_data.get(key), analysis_data.get(key),
) )
@@ -252,7 +259,7 @@ class SoundNormalizerService:
ffmpeg.run(stream, quiet=True, overwrite_output=True) ffmpeg.run(stream, quiet=True, overwrite_output=True)
logger.info("Two-pass normalization completed: %s", output_path) logger.info("Two-pass normalization completed: %s", output_path)
except ffmpeg.Error as e: except ffmpeg.Error as e:
logger.error( logger.exception(
"FFmpeg second pass failed for %s. Stdout: %s, Stderr: %s", "FFmpeg second pass failed for %s. Stdout: %s, Stderr: %s",
input_path, input_path,
e.stdout.decode() if e.stdout else "None", e.stdout.decode() if e.stdout else "None",
@@ -267,12 +274,14 @@ class SoundNormalizerService:
async def normalize_sound( async def normalize_sound(
self, self,
sound: Sound, sound: Sound,
*,
force: bool = False, force: bool = False,
one_pass: bool | None = None, one_pass: bool | None = None,
sound_data: dict | None = None, sound_data: dict | None = None,
) -> NormalizationInfo: ) -> NormalizationInfo:
"""Normalize a single sound.""" """Normalize a single sound."""
# Use provided sound_data to avoid detached instance issues, or capture from sound # Use provided sound_data to avoid detached instance issues,
# or capture from sound
if sound_data: if sound_data:
filename = sound_data["filename"] filename = sound_data["filename"]
sound_id = sound_data["id"] sound_id = sound_data["id"]
@@ -391,6 +400,7 @@ class SoundNormalizerService:
async def normalize_all_sounds( async def normalize_all_sounds(
self, self,
*,
force: bool = False, force: bool = False,
one_pass: bool | None = None, one_pass: bool | None = None,
) -> NormalizationResults: ) -> NormalizationResults:
@@ -409,7 +419,7 @@ class SoundNormalizerService:
if force: if force:
# Get all sounds if forcing # Get all sounds if forcing
sounds = [] sounds = []
for sound_type in self.type_directories.keys(): for sound_type in self.type_directories:
type_sounds = await self.sound_repo.get_by_type(sound_type) type_sounds = await self.sound_repo.get_by_type(sound_type)
sounds.extend(type_sounds) sounds.extend(type_sounds)
else: else:
@@ -419,17 +429,16 @@ class SoundNormalizerService:
logger.info("Found %d sounds to process", len(sounds)) logger.info("Found %d sounds to process", len(sounds))
# Capture all sound data upfront to avoid session detachment issues # Capture all sound data upfront to avoid session detachment issues
sound_data_list = [] sound_data_list = [
for sound in sounds: {
sound_data_list.append( "id": sound.id,
{ "filename": sound.filename,
"id": sound.id, "type": sound.type,
"filename": sound.filename, "is_normalized": sound.is_normalized,
"type": sound.type, "name": sound.name,
"is_normalized": sound.is_normalized, }
"name": sound.name, for sound in sounds
}, ]
)
# Process each sound using captured data # Process each sound using captured data
for i, sound in enumerate(sounds): for i, sound in enumerate(sounds):
@@ -485,6 +494,7 @@ class SoundNormalizerService:
async def normalize_sounds_by_type( async def normalize_sounds_by_type(
self, self,
sound_type: str, sound_type: str,
*,
force: bool = False, force: bool = False,
one_pass: bool | None = None, one_pass: bool | None = None,
) -> NormalizationResults: ) -> NormalizationResults:
@@ -508,17 +518,16 @@ class SoundNormalizerService:
logger.info("Found %d %s sounds to process", len(sounds), sound_type) logger.info("Found %d %s sounds to process", len(sounds), sound_type)
# Capture all sound data upfront to avoid session detachment issues # Capture all sound data upfront to avoid session detachment issues
sound_data_list = [] sound_data_list = [
for sound in sounds: {
sound_data_list.append( "id": sound.id,
{ "filename": sound.filename,
"id": sound.id, "type": sound.type,
"filename": sound.filename, "is_normalized": sound.is_normalized,
"type": sound.type, "name": sound.name,
"is_normalized": sound.is_normalized, }
"name": sound.name, for sound in sounds
}, ]
)
# Process each sound using captured data # Process each sound using captured data
for i, sound in enumerate(sounds): for i, sound in enumerate(sounds):

View File

@@ -23,7 +23,8 @@ class VLCPlayerService:
"""Service for launching VLC instances via subprocess to play sounds.""" """Service for launching VLC instances via subprocess to play sounds."""
def __init__( def __init__(
self, db_session_factory: Callable[[], AsyncSession] | None = None, self,
db_session_factory: Callable[[], AsyncSession] | None = None,
) -> None: ) -> None:
"""Initialize the VLC player service.""" """Initialize the VLC player service."""
self.vlc_executable = self._find_vlc_executable() self.vlc_executable = self._find_vlc_executable()
@@ -52,7 +53,7 @@ class VLCPlayerService:
# For "vlc", try to find it in PATH # For "vlc", try to find it in PATH
if path == "vlc": if path == "vlc":
result = subprocess.run( result = subprocess.run(
["which", "vlc"], ["which", "vlc"], # noqa: S607
capture_output=True, capture_output=True,
check=False, check=False,
text=True, text=True,
@@ -112,13 +113,19 @@ class VLCPlayerService:
# Record play count and emit event # Record play count and emit event
if self.db_session_factory and sound.id: if self.db_session_factory and sound.id:
asyncio.create_task(self._record_play_count(sound.id, sound.name)) task = asyncio.create_task(
self._record_play_count(sound.id, sound.name),
return True )
# Store reference to prevent garbage collection
self._background_tasks = getattr(self, "_background_tasks", set())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
except Exception: except Exception:
logger.exception("Failed to launch VLC for sound %s", sound.name) logger.exception("Failed to launch VLC for sound %s", sound.name)
return False return False
else:
return True
async def stop_all_vlc_instances(self) -> dict[str, Any]: async def stop_all_vlc_instances(self) -> dict[str, Any]:
"""Stop all running VLC processes by killing them. """Stop all running VLC processes by killing them.
@@ -287,7 +294,8 @@ class VLCPlayerService:
logger.info("Broadcasted sound_played event for sound %s", sound_id) logger.info("Broadcasted sound_played event for sound %s", sound_id)
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to broadcast sound_played event for sound %s", sound_id, "Failed to broadcast sound_played event for sound %s",
sound_id,
) )
except Exception: except Exception:
@@ -297,7 +305,6 @@ class VLCPlayerService:
await session.close() await session.close()
# Global VLC player service instance # Global VLC player service instance
vlc_player_service: VLCPlayerService | None = None vlc_player_service: VLCPlayerService | None = None
@@ -310,3 +317,4 @@ def get_vlc_player_service(
if vlc_player_service is None: if vlc_player_service is None:
vlc_player_service = VLCPlayerService(db_session_factory) vlc_player_service = VLCPlayerService(db_session_factory)
return vlc_player_service return vlc_player_service
return vlc_player_service

View File

@@ -70,7 +70,7 @@ def requires_credits(
# Validate credits before execution # Validate credits before execution
await credit_service.validate_and_reserve_credits( await credit_service.validate_and_reserve_credits(
user_id, action_type, metadata, user_id, action_type,
) )
# Execute the function # Execute the function
@@ -86,7 +86,7 @@ def requires_credits(
finally: finally:
# Deduct credits based on success # Deduct credits based on success
await credit_service.deduct_credits( await credit_service.deduct_credits(
user_id, action_type, success, metadata, user_id, action_type, success=success, metadata=metadata,
) )
return wrapper # type: ignore[return-value] return wrapper # type: ignore[return-value]
@@ -173,7 +173,7 @@ class CreditManager:
async def __aenter__(self) -> "CreditManager": async def __aenter__(self) -> "CreditManager":
"""Enter context manager - validate credits.""" """Enter context manager - validate credits."""
await self.credit_service.validate_and_reserve_credits( await self.credit_service.validate_and_reserve_credits(
self.user_id, self.action_type, self.metadata, self.user_id, self.action_type,
) )
self.validated = True self.validated = True
return self return self
@@ -189,7 +189,7 @@ class CreditManager:
# If no exception occurred, consider it successful # If no exception occurred, consider it successful
success = exc_type is None and self.success success = exc_type is None and self.success
await self.credit_service.deduct_credits( await self.credit_service.deduct_credits(
self.user_id, self.action_type, success, self.metadata, self.user_id, self.action_type, success=success, metadata=self.metadata,
) )
def mark_success(self) -> None: def mark_success(self) -> None:

View File

@@ -168,7 +168,7 @@ class TestCreditService:
mock_socket_manager.send_to_user = AsyncMock() mock_socket_manager.send_to_user = AsyncMock()
transaction = await credit_service.deduct_credits( transaction = await credit_service.deduct_credits(
1, CreditActionType.VLC_PLAY_SOUND, True, {"test": "data"}, 1, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"test": "data"},
) )
# Verify user credits were updated # Verify user credits were updated
@@ -214,7 +214,7 @@ class TestCreditService:
mock_socket_manager.send_to_user = AsyncMock() mock_socket_manager.send_to_user = AsyncMock()
transaction = await credit_service.deduct_credits( transaction = await credit_service.deduct_credits(
1, CreditActionType.VLC_PLAY_SOUND, False, # Action failed 1, CreditActionType.VLC_PLAY_SOUND, success=False, # Action failed
) )
# Verify user credits were NOT updated (action requires success) # Verify user credits were NOT updated (action requires success)
@@ -256,7 +256,7 @@ class TestCreditService:
with pytest.raises(InsufficientCreditsError): with pytest.raises(InsufficientCreditsError):
await credit_service.deduct_credits( await credit_service.deduct_credits(
1, CreditActionType.VLC_PLAY_SOUND, True, 1, CreditActionType.VLC_PLAY_SOUND, success=True,
) )
# Verify no socket event was emitted since credits could not be deducted # Verify no socket event was emitted since credits could not be deducted

View File

@@ -45,10 +45,10 @@ class TestRequiresCreditsDecorator:
assert result == "Success: test" assert result == "Success: test"
mock_credit_service.validate_and_reserve_credits.assert_called_once_with( mock_credit_service.validate_and_reserve_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, None, 123, CreditActionType.VLC_PLAY_SOUND,
) )
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, True, None, 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -70,10 +70,10 @@ class TestRequiresCreditsDecorator:
await test_action(user_id=123, sound_name="test.mp3") await test_action(user_id=123, sound_name="test.mp3")
mock_credit_service.validate_and_reserve_credits.assert_called_once_with( mock_credit_service.validate_and_reserve_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, {"sound_name": "test.mp3"}, 123, CreditActionType.VLC_PLAY_SOUND,
) )
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, True, {"sound_name": "test.mp3"}, 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"sound_name": "test.mp3"},
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -92,7 +92,7 @@ class TestRequiresCreditsDecorator:
assert result is False assert result is False
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, False, None, 123, CreditActionType.VLC_PLAY_SOUND, success=False, metadata=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -111,7 +111,7 @@ class TestRequiresCreditsDecorator:
await test_action(user_id=123) await test_action(user_id=123)
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, False, None, 123, CreditActionType.VLC_PLAY_SOUND, success=False, metadata=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -149,7 +149,7 @@ class TestRequiresCreditsDecorator:
assert result == "test" assert result == "test"
mock_credit_service.validate_and_reserve_credits.assert_called_once_with( mock_credit_service.validate_and_reserve_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, None, 123, CreditActionType.VLC_PLAY_SOUND,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -228,10 +228,10 @@ class TestCreditManager:
manager.mark_success() manager.mark_success()
mock_credit_service.validate_and_reserve_credits.assert_called_once_with( mock_credit_service.validate_and_reserve_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, {"test": "data"}, 123, CreditActionType.VLC_PLAY_SOUND,
) )
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, True, {"test": "data"}, 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"test": "data"},
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -246,7 +246,7 @@ class TestCreditManager:
pass pass
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, False, None, 123, CreditActionType.VLC_PLAY_SOUND, success=False, metadata=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -261,7 +261,7 @@ class TestCreditManager:
raise ValueError("Test error") raise ValueError("Test error")
mock_credit_service.deduct_credits.assert_called_once_with( mock_credit_service.deduct_credits.assert_called_once_with(
123, CreditActionType.VLC_PLAY_SOUND, False, None, 123, CreditActionType.VLC_PLAY_SOUND, success=False, metadata=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio