diff --git a/app/database.py b/app/database.py index 20349b6..cf3ed7e 100644 --- a/app/database.py +++ b/app/database.py @@ -13,6 +13,6 @@ def init_db(app): migrate.init_app(app, db) # Import models here to ensure they are registered with SQLAlchemy - from app.models import user, user_oauth # noqa: F401 + from app.models import user, user_oauth, sound_played # noqa: F401 return db diff --git a/app/models/sound_played.py b/app/models/sound_played.py new file mode 100644 index 0000000..15d1a62 --- /dev/null +++ b/app/models/sound_played.py @@ -0,0 +1,238 @@ +"""Sound played tracking model.""" + +from datetime import datetime +from typing import List, Optional + +from app.database import db +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class SoundPlayed(db.Model): + """Model to track when users play sounds.""" + + __tablename__ = "sound_played" + + id: Mapped[int] = mapped_column(primary_key=True) + + # Foreign keys + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + sound_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sounds.id"), nullable=False + ) + + # Additional context + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Timestamp + played_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False + ) + + # Relationships + user: Mapped["User"] = relationship("User", backref="sounds_played") + sound: Mapped["Sound"] = relationship("Sound", backref="play_history") + + def __repr__(self) -> str: + """String representation of SoundPlayed.""" + return f"" + + def to_dict(self) -> dict: + """Convert sound played record to dictionary.""" + return { + "id": self.id, + "user_id": self.user_id, + "sound_id": self.sound_id, + "ip_address": self.ip_address, + "user_agent": self.user_agent, + "played_at": self.played_at.isoformat(), + "user": { + "id": self.user.id, + "name": self.user.name, + "email": self.user.email, + } if self.user else None, + "sound": { + "id": self.sound.id, + "name": self.sound.name, + "filename": self.sound.filename, + "type": self.sound.type, + } if self.sound else None, + } + + @classmethod + def create_play_record( + cls, + user_id: int, + sound_id: int, + ip_address: str | None = None, + user_agent: str | None = None, + commit: bool = True, + ) -> "SoundPlayed": + """Create a new sound played record.""" + play_record = cls( + user_id=user_id, + sound_id=sound_id, + ip_address=ip_address, + user_agent=user_agent, + ) + + db.session.add(play_record) + if commit: + db.session.commit() + return play_record + + @classmethod + def get_user_plays( + cls, user_id: int, limit: int = 50, offset: int = 0 + ) -> List["SoundPlayed"]: + """Get recent plays for a specific user.""" + return ( + cls.query.filter_by(user_id=user_id) + .order_by(cls.played_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + @classmethod + def get_sound_plays( + cls, sound_id: int, limit: int = 50, offset: int = 0 + ) -> List["SoundPlayed"]: + """Get recent plays for a specific sound.""" + return ( + cls.query.filter_by(sound_id=sound_id) + .order_by(cls.played_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + @classmethod + def get_recent_plays( + cls, limit: int = 100, offset: int = 0 + ) -> List["SoundPlayed"]: + """Get recent plays across all users and sounds.""" + return ( + cls.query.order_by(cls.played_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + @classmethod + def get_user_play_count(cls, user_id: int) -> int: + """Get total play count for a user.""" + return cls.query.filter_by(user_id=user_id).count() + + @classmethod + def get_sound_play_count(cls, sound_id: int) -> int: + """Get total play count for a sound.""" + return cls.query.filter_by(sound_id=sound_id).count() + + @classmethod + def get_popular_sounds( + cls, limit: int = 10, days: int | None = None + ) -> List[dict]: + """Get most popular sounds with play counts.""" + from sqlalchemy import func, text + + query = ( + db.session.query( + cls.sound_id, + func.count(cls.id).label("play_count"), + func.max(cls.played_at).label("last_played"), + ) + .group_by(cls.sound_id) + .order_by(func.count(cls.id).desc()) + ) + + if days: + query = query.filter( + cls.played_at >= text(f"datetime('now', '-{days} days')") + ) + + results = query.limit(limit).all() + + # Get sound details + popular_sounds = [] + for result in results: + from app.models.sound import Sound + + sound = Sound.query.get(result.sound_id) + if sound: + popular_sounds.append({ + "sound": sound.to_dict(), + "play_count": result.play_count, + "last_played": result.last_played.isoformat() if result.last_played else None, + }) + + return popular_sounds + + @classmethod + def get_user_stats(cls, user_id: int) -> dict: + """Get comprehensive stats for a user.""" + from sqlalchemy import func + + total_plays = cls.query.filter_by(user_id=user_id).count() + + if total_plays == 0: + return { + "total_plays": 0, + "unique_sounds": 0, + "favorite_sound": None, + "first_play": None, + "last_play": None, + } + + # Get unique sounds count + unique_sounds = ( + db.session.query(cls.sound_id) + .filter_by(user_id=user_id) + .distinct() + .count() + ) + + # Get favorite sound + favorite_query = ( + db.session.query( + cls.sound_id, func.count(cls.id).label("play_count") + ) + .filter_by(user_id=user_id) + .group_by(cls.sound_id) + .order_by(func.count(cls.id).desc()) + .first() + ) + + favorite_sound = None + if favorite_query: + from app.models.sound import Sound + + sound = Sound.query.get(favorite_query.sound_id) + if sound: + favorite_sound = { + "sound": sound.to_dict(), + "play_count": favorite_query.play_count, + } + + # Get first and last play dates + first_play = ( + cls.query.filter_by(user_id=user_id) + .order_by(cls.played_at.asc()) + .first() + ) + last_play = ( + cls.query.filter_by(user_id=user_id) + .order_by(cls.played_at.desc()) + .first() + ) + + return { + "total_plays": total_plays, + "unique_sounds": unique_sounds, + "favorite_sound": favorite_sound, + "first_play": first_play.played_at.isoformat() if first_play else None, + "last_play": last_play.played_at.isoformat() if last_play else None, + } \ No newline at end of file diff --git a/app/routes/soundboard.py b/app/routes/soundboard.py index 7a22fcc..08b47c3 100644 --- a/app/routes/soundboard.py +++ b/app/routes/soundboard.py @@ -2,8 +2,9 @@ from flask import Blueprint, jsonify, request from app.models.sound import Sound, SoundType +from app.models.sound_played import SoundPlayed from app.services.vlc_service import vlc_service -from app.services.decorators import require_auth +from app.services.decorators import require_auth, get_current_user bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard") @@ -42,7 +43,20 @@ def get_sounds(): def play_sound(sound_id: int): """Play a specific sound.""" try: - success = vlc_service.play_sound(sound_id) + # Get current user for tracking + user = get_current_user() + user_id = int(user["id"]) if user else None + + # Get client information + ip_address = request.remote_addr + user_agent = request.headers.get("User-Agent") + + success = vlc_service.play_sound( + sound_id=sound_id, + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + ) if success: return jsonify({"message": "Sound playing", "sound_id": sound_id}) @@ -119,3 +133,86 @@ def get_status(): ) except Exception as e: return jsonify({"error": str(e)}), 500 + + +@bp.route("/history", methods=["GET"]) +@require_auth +def get_play_history(): + """Get recent play history.""" + try: + page = int(request.args.get("page", 1)) + per_page = min(int(request.args.get("per_page", 50)), 100) + offset = (page - 1) * per_page + + recent_plays = SoundPlayed.get_recent_plays(limit=per_page, offset=offset) + + return jsonify({ + "plays": [play.to_dict() for play in recent_plays], + "page": page, + "per_page": per_page, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/my-history", methods=["GET"]) +@require_auth +def get_my_play_history(): + """Get current user's play history.""" + try: + user = get_current_user() + if not user: + return jsonify({"error": "User not found"}), 404 + + user_id = int(user["id"]) + page = int(request.args.get("page", 1)) + per_page = min(int(request.args.get("per_page", 50)), 100) + offset = (page - 1) * per_page + + user_plays = SoundPlayed.get_user_plays(user_id=user_id, limit=per_page, offset=offset) + + return jsonify({ + "plays": [play.to_dict() for play in user_plays], + "page": page, + "per_page": per_page, + "user_id": user_id, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/my-stats", methods=["GET"]) +@require_auth +def get_my_stats(): + """Get current user's play statistics.""" + try: + user = get_current_user() + if not user: + return jsonify({"error": "User not found"}), 404 + + user_id = int(user["id"]) + stats = SoundPlayed.get_user_stats(user_id) + + return jsonify(stats) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/popular", methods=["GET"]) +@require_auth +def get_popular_sounds(): + """Get most popular sounds.""" + try: + limit = min(int(request.args.get("limit", 10)), 50) + days = request.args.get("days") + days = int(days) if days and days.isdigit() else None + + popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days) + + return jsonify({ + "popular_sounds": popular_sounds, + "limit": limit, + "days": days, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/app/services/vlc_service.py b/app/services/vlc_service.py index 0df64d3..598a582 100644 --- a/app/services/vlc_service.py +++ b/app/services/vlc_service.py @@ -9,6 +9,7 @@ from typing import Dict, List, Optional from app.database import db from app.models.sound import Sound +from app.models.sound_played import SoundPlayed class VLCService: @@ -19,7 +20,7 @@ class VLCService: self.processes: Dict[str, subprocess.Popen] = {} self.lock = threading.Lock() - def play_sound(self, sound_id: int) -> bool: + def play_sound(self, sound_id: int, user_id: int | None = None, ip_address: str | None = None, user_agent: str | None = None) -> bool: """Play a sound by ID using VLC subprocess.""" try: # Get sound from database @@ -79,6 +80,19 @@ class VLCService: # Increment play count sound.increment_play_count() + # Record play event if user is provided + if user_id: + try: + SoundPlayed.create_play_record( + user_id=user_id, + sound_id=sound_id, + ip_address=ip_address, + user_agent=user_agent, + commit=True, + ) + except Exception as e: + print(f"Error recording play event: {e}") + # Schedule cleanup after sound duration threading.Thread( target=self._cleanup_after_playback,