initial
This commit is contained in:
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
39
backend/services/audio_service.py
Normal file
39
backend/services/audio_service.py
Normal 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)}")
|
||||
285
backend/services/game_service.py
Normal file
285
backend/services/game_service.py
Normal 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')
|
||||
78
backend/services/image_service.py
Normal file
78
backend/services/image_service.py
Normal 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)}")
|
||||
71
backend/services/youtube_service.py
Normal file
71
backend/services/youtube_service.py
Normal 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
|
||||
Reference in New Issue
Block a user