feat(sound_played): add sound play tracking and user statistics endpoints; enhance VLC service to record play events
This commit is contained in:
@@ -13,6 +13,6 @@ def init_db(app):
|
|||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
|
|
||||||
# Import models here to ensure they are registered with SQLAlchemy
|
# 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
|
return db
|
||||||
|
|||||||
238
app/models/sound_played.py
Normal file
238
app/models/sound_played.py
Normal file
@@ -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"<SoundPlayed user_id={self.user_id} sound_id={self.sound_id} at={self.played_at}>"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from app.models.sound import Sound, SoundType
|
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.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")
|
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
|
||||||
|
|
||||||
@@ -42,7 +43,20 @@ def get_sounds():
|
|||||||
def play_sound(sound_id: int):
|
def play_sound(sound_id: int):
|
||||||
"""Play a specific sound."""
|
"""Play a specific sound."""
|
||||||
try:
|
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:
|
if success:
|
||||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||||
@@ -119,3 +133,86 @@ def get_status():
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
|
from app.models.sound_played import SoundPlayed
|
||||||
|
|
||||||
|
|
||||||
class VLCService:
|
class VLCService:
|
||||||
@@ -19,7 +20,7 @@ class VLCService:
|
|||||||
self.processes: Dict[str, subprocess.Popen] = {}
|
self.processes: Dict[str, subprocess.Popen] = {}
|
||||||
self.lock = threading.Lock()
|
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."""
|
"""Play a sound by ID using VLC subprocess."""
|
||||||
try:
|
try:
|
||||||
# Get sound from database
|
# Get sound from database
|
||||||
@@ -79,6 +80,19 @@ class VLCService:
|
|||||||
# Increment play count
|
# Increment play count
|
||||||
sound.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
|
# Schedule cleanup after sound duration
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._cleanup_after_playback,
|
target=self._cleanup_after_playback,
|
||||||
|
|||||||
Reference in New Issue
Block a user