Files
SpotifyRecAlg/swingmusic/services/recently_played_buffer.py
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

221 lines
6.4 KiB
Python

"""
Recently Played Buffer using DragonflyDB.
Provides instant access to recently played tracks using a fast circular buffer
stored in DragonflyDB. This eliminates the need for database queries for the
most common "recently played" use case.
"""
import json
import logging
import time
from typing import Any
from swingmusic.db.dragonfly_client import get_dragonfly_client
logger = logging.getLogger(__name__)
# Maximum number of tracks to keep in the recently played buffer
MAX_BUFFER_SIZE = 100
# TTL for recently played entries (30 days)
BUFFER_TTL = 30 * 24 * 60 * 60
class RecentlyPlayedBuffer:
"""
Manages recently played tracks using DragonflyDB lists.
Uses a circular buffer pattern with Redis lists (LPUSH + LTRIM)
to maintain a fixed-size buffer of recently played tracks per user.
"""
def __init__(self, max_size: int = MAX_BUFFER_SIZE):
self.max_size = max_size
self._client = None
@property
def client(self):
if self._client is None:
self._client = get_dragonfly_client()
return self._client
def _get_key(self, userid: int) -> str:
"""Get the Redis key for a user's recently played buffer."""
return f"recently_played:user:{userid}"
def add_track(self, userid: int, track_data: dict[str, Any]) -> bool:
"""
Add a track to the user's recently played buffer.
Args:
userid: The user ID
track_data: Track metadata including trackhash, title, artist, etc.
Returns:
True if successful, False otherwise
"""
if not self.client.is_available():
return False
try:
key = self._get_key(userid)
# Add timestamp to track data
entry = {
**track_data,
"played_at": int(time.time()),
}
# Use pipeline for atomic operations
pipe = self.client.client.pipeline()
# Push to front of list
pipe.lpush(key, json.dumps(entry))
# Trim to max size (keep only first max_size elements)
pipe.ltrim(key, 0, self.max_size - 1)
# Set TTL
pipe.expire(key, BUFFER_TTL)
pipe.execute()
logger.debug(f"Added track to recently played for user {userid}")
return True
except Exception as e:
logger.error(f"Failed to add track to recently played buffer: {e}")
return False
def get_recent_tracks(
self, userid: int, limit: int = 20, offset: int = 0
) -> list[dict[str, Any]]:
"""
Get recently played tracks for a user.
Args:
userid: The user ID
limit: Maximum number of tracks to return
offset: Number of tracks to skip
Returns:
List of track data dictionaries, most recent first
"""
if not self.client.is_available():
return []
try:
key = self._get_key(userid)
# Get range from list (LRANGE is 0-indexed, inclusive)
end = offset + limit - 1
results = self.client.client.lrange(key, offset, end)
tracks = []
for result in results:
try:
tracks.append(json.loads(result))
except json.JSONDecodeError:
continue
return tracks
except Exception as e:
logger.error(f"Failed to get recently played tracks: {e}")
return []
def get_track_count(self, userid: int) -> int:
"""Get the number of tracks in the user's recently played buffer."""
if not self.client.is_available():
return 0
try:
key = self._get_key(userid)
return self.client.client.llen(key)
except Exception:
return 0
def clear_buffer(self, userid: int) -> bool:
"""Clear the recently played buffer for a user."""
if not self.client.is_available():
return False
try:
key = self._get_key(userid)
self.client.client.delete(key)
return True
except Exception:
return False
def remove_track(self, userid: int, trackhash: str) -> bool:
"""
Remove a specific track from the buffer.
Note: This requires reading, filtering, and rewriting the list,
so it's more expensive than other operations.
"""
if not self.client.is_available():
return False
try:
key = self._get_key(userid)
# Get all tracks
all_tracks = self.client.client.lrange(key, 0, -1)
# Filter out the track to remove
filtered = []
for track_json in all_tracks:
track = json.loads(track_json)
if track.get("trackhash") != trackhash:
filtered.append(track_json)
# Delete and rewrite if changed
if len(filtered) != len(all_tracks):
pipe = self.client.client.pipeline()
pipe.delete(key)
if filtered:
pipe.rpush(key, *filtered)
pipe.expire(key, BUFFER_TTL)
pipe.execute()
return True
except Exception as e:
logger.error(f"Failed to remove track from buffer: {e}")
return False
def get_last_played_track(self, userid: int) -> dict[str, Any] | None:
"""Get the most recently played track for a user."""
tracks = self.get_recent_tracks(userid, limit=1)
return tracks[0] if tracks else None
def is_track_recently_played(
self, userid: int, trackhash: str, within_seconds: int = 3600
) -> bool:
"""
Check if a track was played recently (within the specified time).
Useful for preventing duplicate "recently played" entries.
"""
tracks = self.get_recent_tracks(userid, limit=10)
now = int(time.time())
for track in tracks:
if track.get("trackhash") == trackhash:
played_at = track.get("played_at", 0)
if now - played_at < within_seconds:
return True
return False
# Global instance
recently_played_buffer = RecentlyPlayedBuffer()
def get_recently_played_buffer() -> RecentlyPlayedBuffer:
"""Get the global recently played buffer instance."""
return recently_played_buffer