diff --git a/app/api/v1/sounds.py b/app/api/v1/sounds.py index 2bf32cc..46c82df 100644 --- a/app/api/v1/sounds.py +++ b/app/api/v1/sounds.py @@ -400,7 +400,6 @@ async def play_sound_with_vlc( await credit_service.validate_and_reserve_credits( current_user.id, CreditActionType.VLC_PLAY_SOUND, - {"sound_id": sound_id, "sound_name": sound.name}, ) except InsufficientCreditsError as e: raise HTTPException( @@ -418,8 +417,8 @@ async def play_sound_with_vlc( await credit_service.deduct_credits( current_user.id, CreditActionType.VLC_PLAY_SOUND, - success, - {"sound_id": sound_id, "sound_name": sound.name}, + success=success, + metadata={"sound_id": sound_id, "sound_name": sound.name}, ) if not success: diff --git a/app/services/credit.py b/app/services/credit.py index f4bd532..59a87fe 100644 --- a/app/services/credit.py +++ b/app/services/credit.py @@ -76,14 +76,12 @@ class CreditService: self, user_id: int, action_type: CreditActionType, - metadata: dict[str, Any] | None = None, ) -> tuple[User, CreditAction]: """Validate user has sufficient credits and optionally reserve them. Args: user_id: The user ID action_type: The type of action - metadata: Optional metadata to store with transaction Returns: Tuple of (user, credit_action) @@ -118,6 +116,7 @@ class CreditService: self, user_id: int, action_type: CreditActionType, + *, success: bool = True, metadata: dict[str, Any] | None = None, ) -> CreditTransaction: @@ -139,18 +138,26 @@ class CreditService: """ action = get_credit_action(action_type) - # Only deduct if action requires success and was successful, or doesn't require success - should_deduct = (action.requires_success and success) or not action.requires_success + # Only deduct if action requires success and was successful, + # or doesn't require success + should_deduct = ( + action.requires_success and success + ) or not action.requires_success if not should_deduct: 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, action_type.value, ) # Still create a transaction record for auditing 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() @@ -204,14 +211,19 @@ class CreditService: "action_type": action_type.value, "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) except 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, ) - - return transaction + else: + return transaction except Exception: await session.rollback() @@ -292,14 +304,19 @@ class CreditService: "description": description, "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) except 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, ) - - return transaction + else: + return transaction except Exception: await session.rollback() @@ -312,6 +329,7 @@ class CreditService: user_id: int, action: CreditAction, amount: int, + *, success: bool, metadata: dict[str, Any] | None = None, ) -> CreditTransaction: @@ -342,19 +360,22 @@ class CreditService: amount=amount, balance_before=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, metadata_json=json.dumps(metadata) if metadata else None, ) session.add(transaction) await session.commit() - - return transaction - except Exception: await session.rollback() raise + else: + return transaction finally: await session.close() diff --git a/app/services/extraction.py b/app/services/extraction.py index 6b040c4..c217a5b 100644 --- a/app/services/extraction.py +++ b/app/services/extraction.py @@ -75,7 +75,10 @@ class ExtractionService: extraction = await self.extraction_repo.create(extraction_data) logger.info("Created extraction with ID: %d", extraction.id) - + except Exception: + logger.exception("Failed to create extraction for URL: %s", url) + raise + else: return { "id": extraction.id or 0, # Should never be None for created extraction "url": extraction.url, @@ -87,10 +90,6 @@ class ExtractionService: "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: """Detect service information from URL using yt-dlp.""" try: @@ -126,10 +125,12 @@ class ExtractionService: """Process an extraction job.""" extraction = await self.extraction_repo.get_by_id(extraction_id) 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": - 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 user_id = extraction.user_id @@ -150,7 +151,8 @@ class ExtractionService: service_info = await self._detect_service_info(extraction_url) 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 existing = await self.extraction_repo.get_by_service_and_id( @@ -222,7 +224,12 @@ class ExtractionService: ) 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 { "id": extraction_id, "url": extraction_url, @@ -234,12 +241,6 @@ class ExtractionService: "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 await self.extraction_repo.update( extraction, @@ -313,7 +314,8 @@ class ExtractionService: ) 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] thumbnail_file = thumbnail_files[0] if thumbnail_files else None @@ -324,11 +326,12 @@ class ExtractionService: thumbnail_file or "None", ) - return audio_file, thumbnail_file - except Exception as e: 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( self, @@ -450,8 +453,8 @@ class ExtractionService: else: logger.info("Successfully normalized sound %d", sound_id) - except Exception as e: - logger.exception("Error normalizing sound %d: %s", sound_id, e) + except Exception: + logger.exception("Error normalizing sound %d", sound_id) # Don't fail the extraction if normalization fails async def _add_to_main_playlist(self, sound_id: int, user_id: int) -> None: diff --git a/app/services/extraction_processor.py b/app/services/extraction_processor.py index 00e45e3..4cf2b2f 100644 --- a/app/services/extraction_processor.py +++ b/app/services/extraction_processor.py @@ -1,6 +1,7 @@ """Background extraction processor for handling extraction queue.""" import asyncio +import contextlib from sqlmodel.ext.asyncio.session import AsyncSession @@ -51,10 +52,8 @@ class ExtractionProcessor: "Extraction processor did not stop gracefully, cancelling...", ) self.processor_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self.processor_task - except asyncio.CancelledError: - pass logger.info("Extraction processor stopped") @@ -84,8 +83,8 @@ class ExtractionProcessor: except TimeoutError: continue # Continue processing - except Exception as e: - logger.exception("Error in extraction queue processor: %s", e) + except Exception: + logger.exception("Error in extraction queue processor") # Wait a bit before retrying to avoid tight error loops try: await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0) @@ -156,8 +155,8 @@ class ExtractionProcessor: result["status"], ) - except Exception as e: - logger.exception("Error processing extraction %d: %s", extraction_id, e) + except Exception: + logger.exception("Error processing extraction %d", extraction_id) def _on_extraction_completed(self, extraction_id: int, task: asyncio.Task) -> None: """Handle completion of an extraction task.""" diff --git a/app/services/player.py b/app/services/player.py index 27383f7..ecdc2a5 100644 --- a/app/services/player.py +++ b/app/services/player.py @@ -68,15 +68,19 @@ class PlayerState: "duration": self.current_sound_duration, "index": self.current_sound_index, "current_sound": self._serialize_sound(self.current_sound), - "playlist": { - "id": self.playlist_id, - "name": self.playlist_name, - "length": self.playlist_length, - "duration": self.playlist_duration, - "sounds": [ - self._serialize_sound(sound) for sound in self.playlist_sounds - ], - } if self.playlist_id else None, + "playlist": ( + { + "id": self.playlist_id, + "name": self.playlist_name, + "length": self.playlist_length, + "duration": self.playlist_duration, + "sounds": [ + self._serialize_sound(sound) for sound in self.playlist_sounds + ], + } + if self.playlist_id + else 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.state = PlayerState() 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._is_running = False self._position_thread: threading.Thread | None = None @@ -125,7 +137,8 @@ class PlayerService: # Start position tracking thread self._position_thread = threading.Thread( - target=self._position_tracker, daemon=True, + target=self._position_tracker, + daemon=True, ) self._position_thread.start() @@ -155,9 +168,9 @@ class PlayerService: """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 + index is None + and self.state.status == PlayerStatus.PAUSED + and self.state.current_sound is not None ) if is_resuming: @@ -179,7 +192,14 @@ class PlayerService: } 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: logger.error("Failed to resume playback: VLC error code %s", result) return @@ -204,6 +224,10 @@ class PlayerService: return # 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)) self._player.set_media(media) @@ -354,7 +378,9 @@ class PlayerService: and previous_playlist_id != current_playlist.id ): 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: await self._handle_same_playlist_track_check( @@ -432,7 +458,9 @@ class PlayerService: self._clear_current_track() def _update_playlist_state( - self, current_playlist: Playlist, sounds: list[Sound], + self, + current_playlist: Playlist, + sounds: list[Sound], ) -> None: """Update basic playlist state information.""" self.state.playlist_id = current_playlist.id @@ -464,7 +492,6 @@ class PlayerService: """Get current player state.""" return self.state.to_dict() - def _get_next_index(self, current_index: int) -> int | None: """Get next track index based on current mode.""" if not self.state.playlist_sounds: @@ -497,11 +524,7 @@ class PlayerService: prev_index = current_index - 1 if prev_index < 0: - return ( - playlist_length - 1 - if self.state.mode == PlayerMode.LOOP - else None - ) + return playlist_length - 1 if self.state.mode == PlayerMode.LOOP else None return prev_index def _position_tracker(self) -> None: @@ -535,10 +558,7 @@ class PlayerService: 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 - ): + if not self.state.current_sound_id or self.state.status != PlayerStatus.PLAYING: return sound_id = self.state.current_sound_id @@ -577,10 +597,8 @@ class PlayerService: sound_id, tracking["total_time"], self.state.current_sound_duration, - ( - tracking["total_time"] - / self.state.current_sound_duration - ) * 100, + (tracking["total_time"] / self.state.current_sound_duration) + * 100, ) self._schedule_async_task(self._record_play_count(sound_id)) @@ -596,7 +614,8 @@ class PlayerService: if sound: old_count = sound.play_count await sound_repo.update( - sound, {"play_count": sound.play_count + 1}, + sound, + {"play_count": sound.play_count + 1}, ) logger.info( "Updated sound %s play_count: %s -> %s", diff --git a/app/services/playlist.py b/app/services/playlist.py index fc54019..2b702fe 100644 --- a/app/services/playlist.py +++ b/app/services/playlist.py @@ -69,6 +69,7 @@ class PlaylistService: name: str, description: str | None = None, genre: str | None = None, + *, is_main: bool = False, is_current: bool = False, is_deletable: bool = True, @@ -104,6 +105,7 @@ class PlaylistService: self, playlist_id: int, user_id: int, + *, name: str | None = None, description: str | None = None, genre: str | None = None, @@ -179,7 +181,11 @@ class PlaylistService: return await self.playlist_repo.get_playlist_sounds(playlist_id) 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: """Add a sound to a playlist.""" # Verify playlist exists @@ -202,11 +208,17 @@ class PlaylistService: await self.playlist_repo.add_sound_to_playlist(playlist_id, sound_id, position) 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( - self, playlist_id: int, sound_id: int, user_id: int, + self, + playlist_id: int, + sound_id: int, + user_id: int, ) -> None: """Remove a sound from a playlist.""" # Verify playlist exists @@ -228,7 +240,10 @@ class PlaylistService: ) 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: """Reorder sounds in a playlist.""" # Verify playlist exists @@ -262,7 +277,8 @@ class PlaylistService: await self._unset_current_playlist(user_id) await self._set_main_as_current(user_id) 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]: @@ -286,11 +302,13 @@ class PlaylistService: main_playlist = await self.get_main_playlist() 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 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) logger.info( diff --git a/app/services/socket.py b/app/services/socket.py index 74522ab..3e18125 100644 --- a/app/services/socket.py +++ b/app/services/socket.py @@ -13,7 +13,8 @@ logger = logging.getLogger(__name__) class SocketManager: """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( cors_allowed_origins=["http://localhost:8001"], logger=True, @@ -27,20 +28,20 @@ class SocketManager: self._setup_handlers() - def _setup_handlers(self): + def _setup_handlers(self) -> None: """Set up socket event handlers.""" @self.sio.event - async def connect(sid, environ, auth=None): + async def connect(sid: str, environ: dict) -> None: """Handle client connection.""" - logger.info(f"Client {sid} attempting to connect") + logger.info("Client %s attempting to connect", sid) # Extract access token from cookies cookie_header = environ.get("HTTP_COOKIE", "") access_token = extract_access_token_from_cookies(cookie_header) 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) return @@ -50,13 +51,13 @@ class SocketManager: user_id = payload.get("sub") 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) return - logger.info(f"User {user_id} connected with socket {sid}") - except Exception as e: - logger.warning(f"Client {sid} invalid token: {e}") + logger.info("User %s connected with socket %s", user_id, sid) + except Exception: + logger.exception("Client %s invalid token", sid) await self.sio.disconnect(sid) return @@ -70,7 +71,7 @@ class SocketManager: # Update room tracking 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 await self.sio.emit( @@ -84,33 +85,33 @@ class SocketManager: ) @self.sio.event - async def disconnect(sid): + async def disconnect(sid: str) -> None: """Handle client disconnection.""" user_id = self.socket_users.get(sid) 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 del self.socket_users[sid] if user_id in self.user_rooms: del self.user_rooms[user_id] 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.""" room_id = self.user_rooms.get(user_id) if 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 - logger.warning(f"User {user_id} not found in any room") + logger.warning("User %s not found in any room", user_id) 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.""" 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: """Get list of currently connected user IDs.""" diff --git a/app/services/sound_normalizer.py b/app/services/sound_normalizer.py index 14a94c9..cd4c5f6 100644 --- a/app/services/sound_normalizer.py +++ b/app/services/sound_normalizer.py @@ -153,7 +153,9 @@ class SoundNormalizerService: """Normalize audio using two-pass loudnorm for better quality.""" try: 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 @@ -177,7 +179,7 @@ class SoundNormalizerService: result = ffmpeg.run(stream, capture_stderr=True, quiet=True) analysis_output = result[1].decode("utf-8") except ffmpeg.Error as e: - logger.error( + logger.exception( "FFmpeg first pass failed for %s. Stdout: %s, Stderr: %s", input_path, e.stdout.decode() if e.stdout else "None", @@ -193,9 +195,11 @@ class SoundNormalizerService: json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', analysis_output) if not json_match: 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()) analysis_data = json.loads(json_match.group()) @@ -211,7 +215,10 @@ class SoundNormalizerService: ]: if str(analysis_data.get(key, "")).lower() in invalid_values: 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, analysis_data.get(key), ) @@ -252,7 +259,7 @@ class SoundNormalizerService: ffmpeg.run(stream, quiet=True, overwrite_output=True) logger.info("Two-pass normalization completed: %s", output_path) except ffmpeg.Error as e: - logger.error( + logger.exception( "FFmpeg second pass failed for %s. Stdout: %s, Stderr: %s", input_path, e.stdout.decode() if e.stdout else "None", @@ -267,12 +274,14 @@ class SoundNormalizerService: async def normalize_sound( self, sound: Sound, + *, force: bool = False, one_pass: bool | None = None, sound_data: dict | None = None, ) -> NormalizationInfo: """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: filename = sound_data["filename"] sound_id = sound_data["id"] @@ -391,6 +400,7 @@ class SoundNormalizerService: async def normalize_all_sounds( self, + *, force: bool = False, one_pass: bool | None = None, ) -> NormalizationResults: @@ -409,7 +419,7 @@ class SoundNormalizerService: if force: # Get all sounds if forcing 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) sounds.extend(type_sounds) else: @@ -419,17 +429,16 @@ class SoundNormalizerService: logger.info("Found %d sounds to process", len(sounds)) # Capture all sound data upfront to avoid session detachment issues - sound_data_list = [] - for sound in sounds: - sound_data_list.append( - { - "id": sound.id, - "filename": sound.filename, - "type": sound.type, - "is_normalized": sound.is_normalized, - "name": sound.name, - }, - ) + sound_data_list = [ + { + "id": sound.id, + "filename": sound.filename, + "type": sound.type, + "is_normalized": sound.is_normalized, + "name": sound.name, + } + for sound in sounds + ] # Process each sound using captured data for i, sound in enumerate(sounds): @@ -485,6 +494,7 @@ class SoundNormalizerService: async def normalize_sounds_by_type( self, sound_type: str, + *, force: bool = False, one_pass: bool | None = None, ) -> NormalizationResults: @@ -508,17 +518,16 @@ class SoundNormalizerService: logger.info("Found %d %s sounds to process", len(sounds), sound_type) # Capture all sound data upfront to avoid session detachment issues - sound_data_list = [] - for sound in sounds: - sound_data_list.append( - { - "id": sound.id, - "filename": sound.filename, - "type": sound.type, - "is_normalized": sound.is_normalized, - "name": sound.name, - }, - ) + sound_data_list = [ + { + "id": sound.id, + "filename": sound.filename, + "type": sound.type, + "is_normalized": sound.is_normalized, + "name": sound.name, + } + for sound in sounds + ] # Process each sound using captured data for i, sound in enumerate(sounds): diff --git a/app/services/vlc_player.py b/app/services/vlc_player.py index 1bc32a9..e73c882 100644 --- a/app/services/vlc_player.py +++ b/app/services/vlc_player.py @@ -23,7 +23,8 @@ class VLCPlayerService: """Service for launching VLC instances via subprocess to play sounds.""" def __init__( - self, db_session_factory: Callable[[], AsyncSession] | None = None, + self, + db_session_factory: Callable[[], AsyncSession] | None = None, ) -> None: """Initialize the VLC player service.""" self.vlc_executable = self._find_vlc_executable() @@ -52,7 +53,7 @@ class VLCPlayerService: # For "vlc", try to find it in PATH if path == "vlc": result = subprocess.run( - ["which", "vlc"], + ["which", "vlc"], # noqa: S607 capture_output=True, check=False, text=True, @@ -112,13 +113,19 @@ class VLCPlayerService: # Record play count and emit event if self.db_session_factory and sound.id: - asyncio.create_task(self._record_play_count(sound.id, sound.name)) - - return True + task = asyncio.create_task( + self._record_play_count(sound.id, sound.name), + ) + # 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: logger.exception("Failed to launch VLC for sound %s", sound.name) return False + else: + return True async def stop_all_vlc_instances(self) -> dict[str, Any]: """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) except 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: @@ -297,7 +305,6 @@ class VLCPlayerService: await session.close() - # Global VLC player service instance vlc_player_service: VLCPlayerService | None = None @@ -310,3 +317,4 @@ def get_vlc_player_service( if vlc_player_service is None: vlc_player_service = VLCPlayerService(db_session_factory) return vlc_player_service + return vlc_player_service diff --git a/app/utils/credit_decorators.py b/app/utils/credit_decorators.py index ffb39bb..dff04e5 100644 --- a/app/utils/credit_decorators.py +++ b/app/utils/credit_decorators.py @@ -70,7 +70,7 @@ def requires_credits( # Validate credits before execution await credit_service.validate_and_reserve_credits( - user_id, action_type, metadata, + user_id, action_type, ) # Execute the function @@ -86,7 +86,7 @@ def requires_credits( finally: # Deduct credits based on success 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] @@ -173,7 +173,7 @@ class CreditManager: async def __aenter__(self) -> "CreditManager": """Enter context manager - validate 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 return self @@ -189,7 +189,7 @@ class CreditManager: # If no exception occurred, consider it successful success = exc_type is None and self.success 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: diff --git a/tests/services/test_credit.py b/tests/services/test_credit.py index f23d697..f1d3fa8 100644 --- a/tests/services/test_credit.py +++ b/tests/services/test_credit.py @@ -168,7 +168,7 @@ class TestCreditService: mock_socket_manager.send_to_user = AsyncMock() 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 @@ -214,7 +214,7 @@ class TestCreditService: mock_socket_manager.send_to_user = AsyncMock() 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) @@ -256,7 +256,7 @@ class TestCreditService: with pytest.raises(InsufficientCreditsError): 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 diff --git a/tests/utils/test_credit_decorators.py b/tests/utils/test_credit_decorators.py index 1970234..f6cb867 100644 --- a/tests/utils/test_credit_decorators.py +++ b/tests/utils/test_credit_decorators.py @@ -45,10 +45,10 @@ class TestRequiresCreditsDecorator: assert result == "Success: test" 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( - 123, CreditActionType.VLC_PLAY_SOUND, True, None, + 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata=None, ) @pytest.mark.asyncio @@ -70,10 +70,10 @@ class TestRequiresCreditsDecorator: await test_action(user_id=123, sound_name="test.mp3") 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( - 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 @@ -92,7 +92,7 @@ class TestRequiresCreditsDecorator: assert result is False 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 @@ -111,7 +111,7 @@ class TestRequiresCreditsDecorator: await test_action(user_id=123) 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 @@ -149,7 +149,7 @@ class TestRequiresCreditsDecorator: assert result == "test" 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 @@ -228,10 +228,10 @@ class TestCreditManager: manager.mark_success() 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( - 123, CreditActionType.VLC_PLAY_SOUND, True, {"test": "data"}, + 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"test": "data"}, ) @pytest.mark.asyncio @@ -246,7 +246,7 @@ class TestCreditManager: pass 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 @@ -261,7 +261,7 @@ class TestCreditManager: raise ValueError("Test error") 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