This commit is contained in:
2025-12-22 14:47:25 -05:00
parent d4e859f9a7
commit 00e9eb8986
81 changed files with 13933 additions and 0 deletions

View File

View File

@@ -0,0 +1,39 @@
import os
import uuid
def allowed_audio_file(filename, allowed_extensions):
"""Check if file has allowed audio extension"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
def get_audio_path(unique_filename):
"""Get URL path for serving audio"""
return f"/static/audio/{unique_filename}"
def generate_audio_filename(extension='mp3'):
"""Generate unique audio filename"""
return f"{uuid.uuid4()}.{extension}"
def delete_audio(audio_path, base_folder):
"""
Delete an audio file
Args:
audio_path: Relative path to audio (e.g., /static/audio/abc123.mp3)
base_folder: Base folder for the application
"""
if not audio_path:
return
relative_path = audio_path.lstrip('/')
full_path = os.path.join(base_folder, relative_path)
try:
if os.path.exists(full_path):
os.remove(full_path)
except Exception as e:
print(f"Error deleting audio {full_path}: {str(e)}")

View File

@@ -0,0 +1,285 @@
from backend.models import db, Game, Score
from flask_socketio import emit
def get_game_state(game):
"""Get current game state with all necessary information"""
current_question = game.get_current_question()
state = {
'game_id': game.id,
'game_name': game.name,
'current_question_index': game.current_question_index,
'total_questions': len(game.game_questions),
'is_active': game.is_active,
'teams': [team.to_dict() for team in game.teams]
}
if current_question:
state['current_question'] = current_question.to_dict(include_answer=False)
return state
def get_admin_game_state(game):
"""Get game state with answer (for admin only)"""
state = get_game_state(game)
current_question = game.get_current_question()
if current_question:
state['current_question'] = current_question.to_dict(include_answer=True)
return state
def start_game(game, socketio_instance):
"""Start/activate a game"""
# Deactivate any other active games
active_games = Game.query.filter_by(is_active=True).all()
for g in active_games:
if g.id != game.id:
g.is_active = False
game.is_active = True
game.current_question_index = 0
db.session.commit()
# Emit game_started event
socketio_instance.emit('game_started', {
'game_id': game.id,
'game_name': game.name,
'total_questions': len(game.game_questions)
}, room=f'game_{game.id}_contestant')
socketio_instance.emit('game_started', {
'game_id': game.id,
'game_name': game.name,
'total_questions': len(game.game_questions)
}, room=f'game_{game.id}_admin')
# Emit first question
broadcast_question_change(game, socketio_instance)
def next_question(game, socketio_instance):
"""Move to next question"""
if game.current_question_index < len(game.game_questions) - 1:
game.current_question_index += 1
db.session.commit()
broadcast_question_change(game, socketio_instance)
return True
return False
def previous_question(game, socketio_instance):
"""Move to previous question"""
if game.current_question_index > 0:
game.current_question_index -= 1
db.session.commit()
broadcast_question_change(game, socketio_instance)
return True
return False
def broadcast_question_change(game, socketio_instance):
"""Broadcast question change to all connected clients"""
current_question = game.get_current_question()
if not current_question:
return
# Emit to contestant room (without answer)
socketio_instance.emit('question_changed', {
'question_index': game.current_question_index,
'question': current_question.to_dict(include_answer=False),
'total_questions': len(game.game_questions)
}, room=f'game_{game.id}_contestant')
# Emit to admin room (with answer)
socketio_instance.emit('question_with_answer', {
'question_index': game.current_question_index,
'question': current_question.to_dict(include_answer=True),
'total_questions': len(game.game_questions)
}, room=f'game_{game.id}_admin')
def award_points(game, team, points, socketio_instance):
"""Award points to a team for the current question"""
# Check if score already exists for this team and question
existing_score = Score.query.filter_by(
team_id=team.id,
question_index=game.current_question_index
).first()
if existing_score:
# Add to existing score
existing_score.points += points
else:
# Create new score
score = Score(
team_id=team.id,
game_id=game.id,
question_index=game.current_question_index,
points=points
)
db.session.add(score)
db.session.commit()
# Get all team scores with full data including lifelines
all_scores = [t.to_dict() for t in game.teams]
# Broadcast score update
score_data = {
'team_id': team.id,
'team_name': team.name,
'new_score': team.total_score,
'points_awarded': points,
'all_scores': all_scores
}
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_contestant')
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_admin')
def toggle_answer_visibility(game, show_answer, socketio_instance):
"""Toggle answer visibility on contestant screen"""
current_question = game.get_current_question()
if not current_question:
return
answer_data = {
'show_answer': show_answer
}
if show_answer:
answer_data['answer'] = current_question.answer
# Broadcast to contestant room only
socketio_instance.emit('answer_visibility_changed', answer_data, room=f'game_{game.id}_contestant')
def toggle_timer_pause(game, paused, socketio_instance):
"""Pause or resume the timer"""
# Broadcast timer pause state to both rooms
socketio_instance.emit('timer_paused', {
'paused': paused
}, room=f'game_{game.id}_contestant')
socketio_instance.emit('timer_paused', {
'paused': paused
}, room=f'game_{game.id}_admin')
def reset_timer(game, socketio_instance):
"""Reset the timer to 30 seconds"""
# Broadcast timer reset to both rooms
socketio_instance.emit('timer_reset', {
'seconds': 30
}, room=f'game_{game.id}_contestant')
socketio_instance.emit('timer_reset', {
'seconds': 30
}, room=f'game_{game.id}_admin')
def end_game(game, socketio_instance):
"""End/deactivate a game"""
game.is_active = False
db.session.commit()
# Emit game_ended event to all rooms
socketio_instance.emit('game_ended', {
'game_id': game.id,
'game_name': game.name
}, room=f'game_{game.id}_contestant')
socketio_instance.emit('game_ended', {
'game_id': game.id,
'game_name': game.name
}, room=f'game_{game.id}_admin')
def restart_game(game, socketio_instance):
"""Restart a game (clear scores and reset to waiting state)"""
# Clear all scores for this game
Score.query.filter_by(game_id=game.id).delete()
# Reset game state
game.is_active = False
game.current_question_index = 0
# Reset phone-a-friend lifelines for all teams
for team in game.teams:
team.phone_a_friend_count = 5
db.session.commit()
# Emit game_ended event to reset contestant view
socketio_instance.emit('game_ended', {
'game_id': game.id,
'game_name': game.name
}, room=f'game_{game.id}_contestant')
# Emit score update to show cleared scores and reset lifelines
socketio_instance.emit('score_updated', {
'team_id': None,
'team_name': None,
'new_score': 0,
'points_awarded': 0,
'all_scores': [t.to_dict() for t in game.teams]
}, room=f'game_{game.id}_contestant')
socketio_instance.emit('score_updated', {
'team_id': None,
'team_name': None,
'new_score': 0,
'points_awarded': 0,
'all_scores': [t.to_dict() for t in game.teams]
}, room=f'game_{game.id}_admin')
def broadcast_lifeline_update(game, team, socketio_instance):
"""Broadcast phone-a-friend lifeline update"""
# Get all team scores with updated lifeline counts
all_scores = [t.to_dict() for t in game.teams]
lifeline_data = {
'team_id': team.id,
'team_name': team.name,
'phone_a_friend_count': team.phone_a_friend_count,
'all_scores': all_scores
}
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_contestant')
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_admin')
def broadcast_audio_play(game, socketio_instance):
"""Broadcast audio play command to contestants"""
socketio_instance.emit('audio_play', {
'game_id': game.id
}, room=f'game_{game.id}_contestant')
def broadcast_audio_pause(game, socketio_instance):
"""Broadcast audio pause command to contestants"""
socketio_instance.emit('audio_pause', {
'game_id': game.id
}, room=f'game_{game.id}_contestant')
def broadcast_audio_stop(game, socketio_instance):
"""Broadcast audio stop command to contestants"""
socketio_instance.emit('audio_stop', {
'game_id': game.id
}, room=f'game_{game.id}_contestant')
def broadcast_audio_seek(game, position, socketio_instance):
"""Broadcast audio seek command to contestants"""
socketio_instance.emit('audio_seek', {
'game_id': game.id,
'position': position
}, room=f'game_{game.id}_contestant')

View File

@@ -0,0 +1,78 @@
import os
import uuid
from werkzeug.utils import secure_filename
from PIL import Image
def allowed_file(filename, allowed_extensions):
"""Check if file has allowed extension"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
def save_image(file, upload_folder, allowed_extensions):
"""
Save uploaded image file with validation
Args:
file: FileStorage object from Flask request
upload_folder: Directory to save images
allowed_extensions: Set of allowed file extensions
Returns:
str: Relative path to saved image, or None if validation fails
Raises:
ValueError: If file validation fails
"""
if not file or file.filename == '':
raise ValueError("No file provided")
if not allowed_file(file.filename, allowed_extensions):
raise ValueError(f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}")
# Verify it's actually an image
try:
img = Image.open(file.stream)
img.verify()
file.stream.seek(0) # Reset stream after verification
except Exception as e:
raise ValueError(f"Invalid image file: {str(e)}")
# Generate unique filename
original_filename = secure_filename(file.filename)
extension = original_filename.rsplit('.', 1)[1].lower()
unique_filename = f"{uuid.uuid4()}.{extension}"
# Ensure upload folder exists
os.makedirs(upload_folder, exist_ok=True)
# Save file
filepath = os.path.join(upload_folder, unique_filename)
file.save(filepath)
# Return relative path (for storing in database)
return f"/static/images/{unique_filename}"
def delete_image(image_path, base_folder):
"""
Delete an image file
Args:
image_path: Relative path to image (e.g., /static/images/abc123.jpg)
base_folder: Base folder for the application
"""
if not image_path:
return
# Remove leading slash and construct full path
relative_path = image_path.lstrip('/')
full_path = os.path.join(base_folder, relative_path)
try:
if os.path.exists(full_path):
os.remove(full_path)
except Exception as e:
# Log error but don't fail the operation
print(f"Error deleting image {full_path}: {str(e)}")

View File

@@ -0,0 +1,71 @@
import re
import yt_dlp
def validate_youtube_url(url):
"""
Validate YouTube URL and extract video ID
Returns:
(bool, str): (is_valid, video_id or error_message)
"""
patterns = [
r'(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)',
r'youtube\.com\/embed\/([\w-]+)',
r'youtube\.com\/v\/([\w-]+)'
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return True, match.group(1)
return False, "Invalid YouTube URL format"
def get_video_duration(url):
"""
Get video duration without downloading
Returns:
int: Duration in seconds, or None if failed
"""
try:
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
return info.get('duration')
except Exception as e:
print(f"Error getting video duration: {e}")
return None
def validate_timestamps(start_time, end_time, video_duration=None):
"""
Validate timestamp range
Args:
start_time: Start time in seconds
end_time: End time in seconds
video_duration: Optional video duration for validation
Returns:
(bool, str): (is_valid, error_message if invalid)
"""
if start_time < 0:
return False, "Start time must be non-negative"
if end_time <= start_time:
return False, "End time must be greater than start time"
if end_time - start_time > 300: # 5 minutes
return False, "Clip duration cannot exceed 5 minutes"
if video_duration and end_time > video_duration:
return False, f"End time exceeds video duration ({video_duration}s)"
return True, None